mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 13:06:21 +00:00
Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78ef131cb7 | |||
| 9ce61d41f7 | |||
| 4d138539ca | |||
| 3c2fc7f368 | |||
| a72ae190a3 | |||
| 4feafb3fcb | |||
| eff527de65 | |||
| c60563fffc | |||
| b36c5a2f1b | |||
| 12bbc56db3 | |||
| 73be58ba12 | |||
| 3ad124ac4f | |||
| e569c8dee0 | |||
| 406cb5554b | |||
| 0486be4773 | |||
| f2ee67c3c5 | |||
| 16ed80701c | |||
| 37bf1bd191 | |||
| e0f97c4920 | |||
| 93698f76f8 | |||
| 2c79b5ab78 | |||
| 5613935b73 | |||
| fb7f0c3e92 | |||
| 08769e5bf1 | |||
| 732a3ae54a | |||
| a8fee05c3e | |||
| c255bfe97d | |||
| f7f2e063d1 | |||
| 39bca4bc1a | |||
| 9b765eb360 | |||
| f68c45cab6 | |||
| 44c569c5db | |||
| 390d82d730 | |||
| 2b44cdd298 | |||
| 345f144b1e | |||
| f780f43863 | |||
| ac1abbaf8b | |||
| b5f98bd745 | |||
| 48d0a759a8 | |||
| 5d135b3ae1 | |||
| 17b3acead6 | |||
| 2c397390b4 | |||
| cd49e98936 | |||
| 7894a0a28e | |||
| 9088a074e2 | |||
| b95720d210 | |||
| 560ec57f75 | |||
| dbca232e35 | |||
| c879629439 | |||
| 1ecf7d2be8 | |||
| 8b5aaeebdf | |||
| 4787bed380 | |||
| 5f25efd54c | |||
| c85be1265f | |||
| 4f1d2d494f | |||
| 3b81a94d76 | |||
| a4d9967e60 | |||
| 6a40eb8a3b | |||
| a23e159ef3 | |||
| 1eb1fca7f2 | |||
| 4100f2f700 | |||
| 23f91d044c | |||
| 06ac87dc45 | |||
| 6d731dd116 | |||
| f804d0fc7c | |||
| b268f44f06 | |||
| 475622a4b9 | |||
| 7b40538486 | |||
| 5531ff7907 | |||
| 4f56868545 | |||
| dc1b43d86c | |||
| 4d7cbfea8e | |||
| e65e2c3628 | |||
| eebf9cb056 | |||
| 3e7ee1fbfc | |||
| 84eff30be1 | |||
| 50a1cc1ec2 | |||
| d49aba748e | |||
| 8a0c3cb36a | |||
| 26d1d6bbfb | |||
| c5ec0ef2a1 | |||
| 6d0c8d710a | |||
| e10265fadd | |||
| c68dfa00df | |||
| b6a47debfd | |||
| 147ff3976f | |||
| 034c7c203b | |||
| b0b6684294 | |||
| 36d2427947 | |||
| 4d15979fab | |||
| 53786e30b6 | |||
| 7300b53e99 | |||
| 6f3897a6e8 | |||
| a6e330cfa9 | |||
| accc173068 | |||
| 81ab8aa07b | |||
| 12ee7c9e9a | |||
| 8d8b60e4f9 | |||
| 19aedcdf56 | |||
| 3bb09e0ef9 | |||
| 13fc65faa2 | |||
| de8761cf29 | |||
| 4f2f0055e1 | |||
| 2290929255 | |||
| a2eab24536 | |||
| b279c108b6 | |||
| 7a6fd8e865 | |||
| 7d599a980f | |||
| 1206db7c12 | |||
| bd61b61843 | |||
| 0c49b0a039 | |||
| 1beb9d4eb6 | |||
| 021fd07deb | |||
| 33f729cd1a | |||
| 8b3c871d08 | |||
| bd8143c464 | |||
| 68762fc4ae | |||
| 1a58d530fb | |||
| ca01385666 | |||
| 5231bbbcac | |||
| 496b10f5c0 | |||
| 1800110748 | |||
| b068c427d4 | |||
| d5eec83a72 | |||
| 6c9cbb07ee | |||
| b92ee0ade5 | |||
| 3327b293d6 | |||
| d7e5d4645d | |||
| 918e4a8fa1 | |||
| f58015bb23 | |||
| e6244aaea6 | |||
| e9d43cb43f | |||
| 5b03f009ee | |||
| 25cf3bfafd | |||
| 3cb7206d90 | |||
| e364b9a516 | |||
| a7e3d198df | |||
| 14cd81b624 | |||
| bd345d35a8 | |||
| 40d0825d79 | |||
| ea725aca9e | |||
| dbdbe16da9 | |||
| 5cd4e390e3 | |||
| 5c17a0d652 | |||
| ec3dd471b1 | |||
| 1d7a0d6bd8 | |||
| 71df4aa473 | |||
| 48d14bfb7e | |||
| 74bcf41fe8 | |||
| 210f020092 | |||
| 306691b4d7 | |||
| f531c65fbb | |||
| 6d742388fa | |||
| aec2d30506 | |||
| eb086b8456 | |||
| 3dd91a04fa | |||
| 9264a9c66d | |||
| f9f7283fec | |||
| 25e851b359 | |||
| f2a95f9ae6 | |||
| 4e0bcf1c4d | |||
| bbcb3304dc | |||
| 3b316e3a4e | |||
| 251e12c7d1 | |||
| 3b13a1b6d4 | |||
| 126db9612f | |||
| dd7819b1be | |||
| 3415df3715 | |||
| 0dc8930750 | |||
| 9f2d7daa17 | |||
| 249483c3e1 | |||
| eb2731183f | |||
| d9c50b97f8 | |||
| 8b445a1dc3 | |||
| be99aaebd0 | |||
| f96edd56fb | |||
| 074de037cd | |||
| 297c884b88 | |||
| 04b32e3152 | |||
| bbd09d6785 | |||
| 6a2ca59592 | |||
| 8aeb47eda3 | |||
| da1bccfd20 | |||
| 03c7a3fd42 | |||
| be8903e707 | |||
| d8534c2966 | |||
| d25db6e6f8 | |||
| df6d8f19f8 | |||
| 8af28a778b | |||
| 6ecae1bbd1 | |||
| 60a59e89f6 | |||
| 7fd6d67fe3 | |||
| 453db9f165 | |||
| 19f90e3d9a | |||
| fee0fe5699 | |||
| 88246e5719 | |||
| aaefe6c0d2 | |||
| cbc9bfccaa | |||
| 3e056ad37a | |||
| 46bac5b540 | |||
| 57ed8f8541 | |||
| 132893549a | |||
| d717d5da20 | |||
| 58fa4f869d | |||
| 32e36e330a | |||
| ee8cab8305 | |||
| 393653e20c | |||
| 560f598789 | |||
| 993dfe1bb0 | |||
| 967302269e | |||
| 674c849254 | |||
| f327e377a6 | |||
| e7be5b1928 | |||
| b54a41968d | |||
| f39f5e9fd6 | |||
| 7be18092d3 | |||
| c60c02bcfe | |||
| ec3443d1db | |||
| e76ab1f990 | |||
| c59c066330 | |||
| 7097167613 | |||
| 2c2795e73a | |||
| 965fc929e1 | |||
| 491aba4dbd | |||
| 6402656ec7 | |||
| f6314cc673 | |||
| cded932f1a | |||
| e7c496352f | |||
| 296c6f3cb3 | |||
| 11318f8ab9 |
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: bot
|
||||
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
|
||||
---
|
||||
|
||||
# Bot System
|
||||
|
||||
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
|
||||
|
||||
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | id | Default mode | Markdown | Edit | Notes |
|
||||
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
|
||||
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
|
||||
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
|
||||
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
|
||||
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
|
||||
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
|
||||
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
|
||||
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
|
||||
|
||||
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
|
||||
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ shipped as websocket but support `webhook` per-provider via `settings.connectionMode`. Legacy rows without that field stay on `webhook` (see `LEGACY_WEBHOOK_PLATFORMS` in `platforms/utils.ts`) — **never add new platforms to that list**.
|
||||
|
||||
## Inbound Flow (one webhook → reply)
|
||||
|
||||
```
|
||||
Platform server
|
||||
│ POST /api/agent/webhooks/[platform]/[appId]
|
||||
▼
|
||||
route.ts ── catch-all `[[...appId]]` route
|
||||
│
|
||||
▼
|
||||
BotMessageRouter (singleton)
|
||||
│ • lazy-loads bot per `platform:applicationId`
|
||||
│ • merges schema defaults + provider.settings (mergeWithDefaults)
|
||||
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
|
||||
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
|
||||
│ • registerCommands: /new (reset topic), /stop (interrupt)
|
||||
│
|
||||
▼
|
||||
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
|
||||
│
|
||||
▼
|
||||
AgentBridgeService.handleMention / handleSubscribedMessage
|
||||
│ • activeThreads guard (no duplicate runs per thread)
|
||||
│ • adds 👀 reaction (eyes), startTyping
|
||||
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
|
||||
│ • extractFiles (buffer → fetchData → url)
|
||||
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
|
||||
│
|
||||
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
|
||||
│ → onAfterStep edits progress message live
|
||||
│ → onComplete edits final reply, splits via splitMessage(charLimit)
|
||||
│
|
||||
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
|
||||
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
|
||||
```
|
||||
|
||||
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### In-memory (default)
|
||||
|
||||
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
|
||||
|
||||
### Queue (`isQueueAgentRuntimeEnabled`)
|
||||
|
||||
`AgentBridgeService.executeWithWebhooks`:
|
||||
|
||||
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
|
||||
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
|
||||
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
|
||||
|
||||
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
|
||||
|
||||
- `type: 'step'` → `handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
|
||||
- `type: 'completion'` → `handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
|
||||
|
||||
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
|
||||
|
||||
## Commands
|
||||
|
||||
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
|
||||
|
||||
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
|
||||
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
|
||||
|
||||
Built-in commands:
|
||||
|
||||
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
|
||||
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
|
||||
|
||||
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands` → `setMyCommands`.
|
||||
|
||||
## Active-thread State (statics on `AgentBridgeService`)
|
||||
|
||||
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
|
||||
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
|
||||
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
|
||||
- `pendingStopThreads: Set<threadId>` — `/stop` arrived before `operationId` existed; consumed once available.
|
||||
|
||||
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
|
||||
|
||||
## Topic Lifecycle in Threads
|
||||
|
||||
- `handleMention` always treats the message as the start of a new conversation.
|
||||
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
|
||||
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
|
||||
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
|
||||
|
||||
## Attachments
|
||||
|
||||
`AgentBridgeService.extractFiles` resolves attachments in priority order:
|
||||
|
||||
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
|
||||
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
|
||||
3. `att.url` — public CDN fallback (Discord, public QQ).
|
||||
|
||||
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
|
||||
|
||||
## Concurrency
|
||||
|
||||
`settings.concurrency` is `'queue'` or `'debounce'`:
|
||||
|
||||
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
|
||||
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
|
||||
|
||||
## Gateway (persistent platforms)
|
||||
|
||||
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
|
||||
|
||||
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
|
||||
|
||||
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
|
||||
- On Vercel + webhook mode → start the client inline (one HTTP call).
|
||||
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
|
||||
|
||||
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
|
||||
|
||||
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
|
||||
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
|
||||
|
||||
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
|
||||
|
||||
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
|
||||
|
||||
## Platform Definitions
|
||||
|
||||
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
connectionMode: 'websocket', // recommended default
|
||||
schema: FieldSchema[], // applicationId + credentials + settings
|
||||
clientFactory: new DiscordClientFactory(),
|
||||
supportsMarkdown?: boolean, // default true
|
||||
supportsMessageEdit?: boolean, // default true
|
||||
documentation?: { portalUrl, setupGuideUrl },
|
||||
}
|
||||
```
|
||||
|
||||
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
|
||||
|
||||
Each platform implements `PlatformClient` (see `platforms/types.ts`):
|
||||
|
||||
- Lifecycle: `start(opts?)`, `stop()`
|
||||
- Inbound: `createAdapter()` → Chat SDK adapter map
|
||||
- Outbound: `getMessenger(platformThreadId)` → `{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
|
||||
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
|
||||
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
|
||||
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
|
||||
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
|
||||
|
||||
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
|
||||
|
||||
## Database
|
||||
|
||||
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
|
||||
|
||||
```ts
|
||||
agent_bot_providers (
|
||||
id uuid pk,
|
||||
agent_id text fk → agents.id (cascade),
|
||||
user_id text fk → users.id (cascade),
|
||||
platform varchar(50), // 'discord' | 'slack' | …
|
||||
application_id varchar(255),
|
||||
credentials text, // KeyVaults-encrypted JSON
|
||||
settings jsonb default '{}',
|
||||
enabled boolean default true,
|
||||
…timestamps
|
||||
)
|
||||
unique (platform, application_id)
|
||||
```
|
||||
|
||||
**Model** (`packages/database/src/models/agentBotProvider.ts`):
|
||||
|
||||
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
|
||||
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
|
||||
|
||||
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
|
||||
|
||||
| Procedure | Notes | |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
|
||||
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
|
||||
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
|
||||
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
|
||||
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
|
||||
| `testConnection` | Calls `clientFactory.validateCredentials` | |
|
||||
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
|
||||
|
||||
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
|
||||
|
||||
## Reply Templates
|
||||
|
||||
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
|
||||
|
||||
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
|
||||
|
||||
## Key Files
|
||||
|
||||
```plaintext
|
||||
Webhook routes:
|
||||
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
|
||||
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
|
||||
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
|
||||
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
|
||||
|
||||
Bot service:
|
||||
src/server/services/bot/index.ts — barrel
|
||||
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
|
||||
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
|
||||
src/server/services/bot/BotCallbackService.ts — qstash callback handler
|
||||
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
|
||||
src/server/services/bot/replyTemplate.ts — render*/splitMessage
|
||||
src/server/services/bot/ackPhrases/ — randomized acks
|
||||
src/server/services/bot/__tests__/ — unit tests for the above
|
||||
|
||||
Platform abstraction:
|
||||
src/server/services/bot/platforms/index.ts — registry singleton + exports
|
||||
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
|
||||
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
|
||||
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
|
||||
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
|
||||
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
|
||||
|
||||
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
|
||||
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
|
||||
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
|
||||
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
|
||||
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
|
||||
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
|
||||
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
|
||||
|
||||
Gateway:
|
||||
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
|
||||
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
|
||||
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
|
||||
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
|
||||
|
||||
Database:
|
||||
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
|
||||
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
|
||||
|
||||
TRPC + client:
|
||||
src/server/routers/lambda/agentBotProvider.ts — TRPC router
|
||||
src/services/agentBotProvider.ts — client wrapper
|
||||
src/store/agent/slices/bot/action.ts — Zustand actions
|
||||
|
||||
UI:
|
||||
src/routes/(main)/agent/channel/list.tsx — channel list
|
||||
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
|
||||
src/routes/(main)/agent/channel/const.ts — platform icons
|
||||
|
||||
Types & runtime status:
|
||||
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
|
||||
```
|
||||
|
||||
## Adding a New Platform
|
||||
|
||||
1. Create `src/server/services/bot/platforms/<id>/`:
|
||||
- `definition.ts` — `PlatformDefinition` registered in `platforms/index.ts`
|
||||
- `schema.ts` — `FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
|
||||
- `client.ts` — `class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
|
||||
- `const.ts` — `DEFAULT_X_CONNECTION_MODE`, history limits, etc.
|
||||
- `protocol-spec.md` — protocol notes (every existing platform has one)
|
||||
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
|
||||
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
|
||||
4. If it can't edit messages, set `supportsMessageEdit: false` — `BotCallbackService` will skip step edits and only send the final reply.
|
||||
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
|
||||
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
|
||||
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
name: cli-backend-testing
|
||||
description: >
|
||||
CLI + Backend integration testing workflow. Use when verifying backend API changes
|
||||
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
|
||||
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
|
||||
'backend test with cli', or when needing to validate server-side changes end-to-end.
|
||||
---
|
||||
|
||||
# CLI + Backend Integration Testing
|
||||
|
||||
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Verifying TRPC router / service / model changes end-to-end
|
||||
- Testing new API fields or response structure changes
|
||||
- Validating CLI command output after backend modifications
|
||||
- Debugging data flow issues between server and CLI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3011` (Next.js) |
|
||||
| CLI source | `lobehub/apps/cli/` |
|
||||
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
|
||||
| Auth | Device Code Flow login to local server |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
All CLI dev commands run from `lobehub/apps/cli/`:
|
||||
|
||||
```bash
|
||||
# Shorthand for all commands below
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Ensure Dev Server is Running
|
||||
|
||||
Check if the dev server is already running:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
|
||||
```
|
||||
|
||||
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
|
||||
- **If unreachable**: start the server:
|
||||
|
||||
```bash
|
||||
# From cloud repo root
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
To **restart** (pick up server-side code changes):
|
||||
|
||||
```bash
|
||||
lsof -ti:3011 | xargs kill
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
|
||||
|
||||
### Step 2: Check CLI Authentication
|
||||
|
||||
Check if dev credentials already exist:
|
||||
|
||||
```bash
|
||||
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
|
||||
```
|
||||
|
||||
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
|
||||
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
|
||||
|
||||
```bash
|
||||
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
|
||||
```
|
||||
|
||||
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
|
||||
|
||||
### Step 3: Test with CLI Commands
|
||||
|
||||
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
|
||||
|
||||
```bash
|
||||
cd lobehub/apps/cli
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
```
|
||||
|
||||
### Step 4: Clean Up Test Data
|
||||
|
||||
Delete any test data created during verification:
|
||||
|
||||
```bash
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Task System
|
||||
|
||||
```bash
|
||||
# List tasks
|
||||
$CLI task list
|
||||
|
||||
# Create test data with nesting
|
||||
$CLI task create -n "Root Task" -i "Test instruction"
|
||||
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
|
||||
|
||||
# View task detail (tests getTaskDetail service)
|
||||
$CLI task view T-1
|
||||
|
||||
# View task tree
|
||||
$CLI task tree T-1
|
||||
|
||||
# Test lifecycle
|
||||
$CLI task edit T-1 --status running
|
||||
$CLI task comment T-1 -m "Test comment"
|
||||
|
||||
# Clean up
|
||||
$CLI task delete T-1 -y
|
||||
```
|
||||
|
||||
### Agent System
|
||||
|
||||
```bash
|
||||
# List agents
|
||||
$CLI agent list
|
||||
|
||||
# View agent detail
|
||||
$CLI agent view <agent-id>
|
||||
|
||||
# Run agent (tests agent execution pipeline)
|
||||
$CLI agent run <agent-id> -m "Test prompt"
|
||||
```
|
||||
|
||||
### Document & Knowledge Base
|
||||
|
||||
```bash
|
||||
# List documents
|
||||
$CLI doc list
|
||||
|
||||
# Create and view
|
||||
$CLI doc create -t "Test Doc" -c "Content here"
|
||||
$CLI doc view <doc-id>
|
||||
|
||||
# Knowledge base
|
||||
$CLI kb list
|
||||
$CLI kb tree <kb-id>
|
||||
```
|
||||
|
||||
### Model & Provider
|
||||
|
||||
```bash
|
||||
# List models and providers
|
||||
$CLI model list
|
||||
$CLI provider list
|
||||
|
||||
# Test provider connectivity
|
||||
$CLI provider test <provider-id>
|
||||
```
|
||||
|
||||
## Dev-Test Cycle
|
||||
|
||||
The standard cycle for backend development:
|
||||
|
||||
```
|
||||
1. Make code changes (service/model/router/type)
|
||||
|
|
||||
2. Run unit tests (fast feedback)
|
||||
bunx vitest run --silent='passed-only' '<test-file>'
|
||||
|
|
||||
3. Restart dev server (if server-side changes)
|
||||
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
||||
|
|
||||
4. CLI verification (end-to-end)
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
|
|
||||
5. Clean up test data
|
||||
```
|
||||
|
||||
### When Server Restart is Needed
|
||||
|
||||
| Change Location | Restart? |
|
||||
| ----------------------------------------- | -------- |
|
||||
| `lobehub/src/server/` (routers, services) | Yes |
|
||||
| `lobehub/packages/database/` (models) | Yes |
|
||||
| `lobehub/packages/types/` | Yes |
|
||||
| `lobehub/packages/prompts/` | Yes |
|
||||
| `lobehub/apps/cli/` (CLI code) | No |
|
||||
| `src/` (cloud overrides) | Yes |
|
||||
|
||||
### When Server Restart is NOT Needed
|
||||
|
||||
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------- | --------------------------------------------------------------------- |
|
||||
| `No authentication found` | Run `login --server http://localhost:3011` |
|
||||
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
|
||||
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
|
||||
|
||||
## Credential Isolation
|
||||
|
||||
| Mode | Credential Dir | Server |
|
||||
| ---------- | -------------------------------- | ----------------- |
|
||||
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
|
||||
| Production | `~/.lobehub/` | `app.lobehub.com` |
|
||||
|
||||
The two environments are completely isolated. Dev mode credentials are gitignored.
|
||||
@@ -1,270 +0,0 @@
|
||||
---
|
||||
name: electron-testing
|
||||
description: Electron desktop app automation testing using agent-browser CLI. Use when testing UI features in the running Electron app, verifying visual state, interacting with the desktop app, or running manual QA scenarios. Triggers on 'test in electron', 'test desktop', 'electron test', 'manual test', or UI verification tasks.
|
||||
---
|
||||
|
||||
# Electron Automation Testing with agent-browser
|
||||
|
||||
Use the `agent-browser` CLI to automate and test the LobeHub desktop Electron app.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `agent-browser` CLI installed globally (`agent-browser --version`)
|
||||
- Working directory must be `apps/desktop/` when starting Electron
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Kill existing instances
|
||||
pkill -f "Electron" 2> /dev/null
|
||||
pkill -f "electron-vite" 2> /dev/null
|
||||
pkill -f "agent-browser" 2> /dev/null
|
||||
sleep 3
|
||||
|
||||
# 2. Start Electron with CDP (MUST cd to apps/desktop first)
|
||||
cd apps/desktop && ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port=9222 > /tmp/electron-dev.log 2>&1 &
|
||||
|
||||
# 3. Wait for startup (poll for "starting electron" in logs)
|
||||
for i in $(seq 1 12); do
|
||||
sleep 5
|
||||
if strings /tmp/electron-dev.log 2> /dev/null | grep -q "starting electron"; then
|
||||
echo "ready"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Wait for renderer to load, then connect
|
||||
sleep 15 && agent-browser --cdp 9222 wait 3000
|
||||
```
|
||||
|
||||
**Critical:** `npx electron-vite dev` MUST run from `apps/desktop/` directory, not project root. Running from root will fail silently (no `initUrl` in logs).
|
||||
|
||||
## Connecting to Electron
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 snapshot -i # Interactive elements only
|
||||
agent-browser --cdp 9222 snapshot -i -C # Include contenteditable elements
|
||||
```
|
||||
|
||||
Always use `--cdp 9222`. The `--auto-connect` flag is unreliable.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1. Snapshot → Find Elements
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 snapshot -i
|
||||
```
|
||||
|
||||
Returns element refs like `@e1`, `@e2`. **Refs are ephemeral** — re-snapshot after any page change (click, navigation, HMR).
|
||||
|
||||
### 2. Interact
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 click @e5
|
||||
agent-browser --cdp 9222 type @e3 "text" # Character by character (for contenteditable)
|
||||
agent-browser --cdp 9222 fill @e3 "text" # Bulk fill (for regular inputs)
|
||||
agent-browser --cdp 9222 press Enter
|
||||
agent-browser --cdp 9222 scroll down 500
|
||||
```
|
||||
|
||||
### 3. Wait
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 wait 2000 # Wait ms
|
||||
agent-browser --cdp 9222 wait --load networkidle # Wait for network
|
||||
```
|
||||
|
||||
Avoid `agent-browser wait` for long durations (>30s) — it blocks the daemon. Use `sleep N` in bash instead, then take a new snapshot/screenshot.
|
||||
|
||||
### 4. Screenshot & Verify
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 screenshot # Save to ~/.agent-browser/tmp/screenshots/
|
||||
agent-browser --cdp 9222 get text @e1 # Get element text
|
||||
agent-browser --cdp 9222 get url # Get current URL
|
||||
```
|
||||
|
||||
Read screenshots with the `Read` tool for visual verification.
|
||||
|
||||
### 5. Evaluate JavaScript
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval "document.title"
|
||||
```
|
||||
|
||||
For multi-line JS, use `--stdin`:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
return JSON.stringify({
|
||||
totalOps: Object.keys(chat.operations).length,
|
||||
queue: chat.queuedMessages,
|
||||
});
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
## LobeHub-Specific Patterns
|
||||
|
||||
### Access Zustand Store State
|
||||
|
||||
The app exposes stores via `window.__LOBE_STORES` (dev mode only):
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
return JSON.stringify({
|
||||
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
|
||||
activeAgent: chat.activeAgentId,
|
||||
activeTopic: chat.activeTopicId,
|
||||
});
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
### Find the Chat Input
|
||||
|
||||
The chat input is a contenteditable div. Regular `snapshot -i` won't find it — use `-C`:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
|
||||
# Output: - generic [ref=e48] editable [contenteditable]:
|
||||
```
|
||||
|
||||
### Navigate to an Agent
|
||||
|
||||
```bash
|
||||
# Snapshot to find agent links in sidebar
|
||||
agent-browser --cdp 9222 snapshot -i 2>&1 | grep -i "agent-name"
|
||||
# Click the agent link
|
||||
agent-browser --cdp 9222 click @e<ref>
|
||||
agent-browser --cdp 9222 wait 2000
|
||||
```
|
||||
|
||||
### Send a Chat Message
|
||||
|
||||
```bash
|
||||
# 1. Find contenteditable input
|
||||
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
|
||||
# 2. Click, type, send
|
||||
agent-browser --cdp 9222 click @e<ref>
|
||||
agent-browser --cdp 9222 type @e<ref> "Hello world"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
```
|
||||
|
||||
### Wait for Agent to Complete
|
||||
|
||||
Don't use `agent-browser wait` for long AI generation. Use `sleep` + screenshot:
|
||||
|
||||
```bash
|
||||
sleep 60 && agent-browser --cdp 9222 scroll down 5000 && agent-browser --cdp 9222 screenshot
|
||||
```
|
||||
|
||||
Or poll the store for operation status:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
var running = ops.filter(function(o) { return o.status === 'running'; });
|
||||
return running.length === 0 ? 'done' : 'running: ' + running.length;
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
### Install Error Interceptor
|
||||
|
||||
Capture `console.error` from the app for debugging:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
window.__CAPTURED_ERRORS = [];
|
||||
var orig = console.error;
|
||||
console.error = function() {
|
||||
var msg = Array.from(arguments).map(function(a) {
|
||||
if (a instanceof Error) return a.message;
|
||||
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
||||
}).join(' ');
|
||||
window.__CAPTURED_ERRORS.push(msg);
|
||||
orig.apply(console, arguments);
|
||||
};
|
||||
return 'installed';
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# Later, check captured errors:
|
||||
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
|
||||
```
|
||||
|
||||
## Screen Recording
|
||||
|
||||
Record automated demos by combining `ffmpeg` screen capture with `agent-browser` automation. The script `.agents/skills/electron-testing/record-electron-demo.sh` handles the full lifecycle.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Run the built-in demo (queue-edit feature)
|
||||
./.agents/skills/electron-testing/record-electron-demo.sh
|
||||
|
||||
# Run a custom automation script
|
||||
./.agents/skills/electron-testing/record-electron-demo.sh ./my-demo.sh /tmp/my-demo.mp4
|
||||
```
|
||||
|
||||
The script automatically:
|
||||
|
||||
1. Starts Electron with CDP and waits for SPA to load
|
||||
2. Detects the window position, screen, and Retina scale via Swift/CGWindowList
|
||||
3. Records only the Electron window region using `ffmpeg -f avfoundation` with crop
|
||||
4. Runs the demo (built-in or custom script receiving CDP port as `$1`)
|
||||
5. Stops recording and cleans up
|
||||
|
||||
### Writing Custom Demo Scripts
|
||||
|
||||
Create a shell script that receives the CDP port as `$1`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# my-demo.sh — Custom demo script
|
||||
PORT=$1
|
||||
|
||||
# Navigate
|
||||
agent-browser --cdp "$PORT" snapshot -i 2>&1 | grep 'link "Lobe AI"'
|
||||
agent-browser --cdp "$PORT" click @e34
|
||||
sleep 3
|
||||
|
||||
# Find input and type
|
||||
INPUT=$(agent-browser --cdp "$PORT" snapshot -i -C 2>&1 \
|
||||
| grep "editable" | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=//')
|
||||
agent-browser --cdp "$PORT" click "@$INPUT"
|
||||
agent-browser --cdp "$PORT" type "@$INPUT" "Hello world"
|
||||
agent-browser --cdp "$PORT" press Enter
|
||||
sleep 5
|
||||
```
|
||||
|
||||
### Key Details
|
||||
|
||||
- **Multi-monitor support**: Uses Swift to find which screen the Electron window is on and calculates relative crop coordinates
|
||||
- **Retina aware**: Scales crop coordinates by the display's `backingScaleFactor`
|
||||
- **No window resize**: Records the window at its current position/size to avoid triggering SPA reload
|
||||
- **SPA load polling**: Waits for interactive elements to appear before starting the demo
|
||||
- **Prerequisites**: `ffmpeg` (`brew install ffmpeg`), `agent-browser`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently
|
||||
- **HMR invalidates everything** — after code changes, refs break, page may crash. Re-snapshot or restart Electron
|
||||
- **`agent-browser wait` blocks the daemon** — for waits >30s, use bash `sleep` instead
|
||||
- **Daemon can get stuck** — if commands hang, `pkill -f agent-browser` to reset the daemon
|
||||
- **`snapshot -i` doesn't find contenteditable** — always use `snapshot -i -C` to find rich text editors
|
||||
- **`fill` doesn't work on contenteditable** — use `type` for the chat input
|
||||
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
|
||||
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__` — use `.chat()` to get current state
|
||||
- **Don't resize the Electron window after load** — resizing triggers a full SPA reload (splash screen), which can take 30+ seconds or get stuck. Record at the window's current size instead
|
||||
- **`screencapture -V -l<windowid>`** doesn't work reliably for video — use `ffmpeg -f avfoundation` with crop instead (see Screen Recording section)
|
||||
@@ -20,9 +20,11 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
2. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
3. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
4. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
|
||||
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
|
||||
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
---
|
||||
name: local-testing
|
||||
description: >
|
||||
Local app and bot testing. Uses agent-browser CLI for Electron/web app UI testing,
|
||||
and osascript (AppleScript) for controlling native macOS apps (WeChat, Discord, Telegram, Slack, Lark/飞书, QQ)
|
||||
to test bots. Triggers on 'local test', 'test in electron', 'test desktop', 'test bot',
|
||||
'bot test', 'test in discord', 'test in telegram', 'test in slack', 'test in weixin',
|
||||
'test in wechat', 'test in lark', 'test in feishu', 'test in qq',
|
||||
'manual test', 'osascript', or UI/bot verification tasks.
|
||||
---
|
||||
|
||||
# Local App & Bot Testing
|
||||
|
||||
Two approaches for local testing on macOS:
|
||||
|
||||
| Approach | Tool | Best For |
|
||||
| --------------------------- | ------------------- | ---------------------------------------------------- |
|
||||
| **agent-browser + CDP** | `agent-browser` CLI | Electron apps, web apps (DOM access, JS eval) |
|
||||
| **osascript (AppleScript)** | `osascript -e` | Native macOS apps (WeChat, Discord, Telegram, Slack) |
|
||||
|
||||
---
|
||||
|
||||
# Part 1: agent-browser (Electron / Web Apps)
|
||||
|
||||
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
|
||||
|
||||
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
|
||||
1. **Navigate**: `agent-browser open <url>`
|
||||
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
|
||||
3. **Interact**: Use refs to click, fill, select
|
||||
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Command Chaining
|
||||
|
||||
```bash
|
||||
# Chain open + wait + snapshot in one call
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser close # Close browser
|
||||
agent-browser close --all # Close all active sessions
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1 # Click element
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser fill @e2 "text" # Clear and type text
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option" # Select dropdown option
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
|
||||
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
|
||||
# Network
|
||||
agent-browser network requests # Inspect tracked requests
|
||||
agent-browser network requests --type xhr,fetch # Filter by resource type
|
||||
agent-browser network requests --method POST # Filter by HTTP method
|
||||
agent-browser network route "**/api/*" --abort # Block matching requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop ./capture.har # Stop and save HAR file
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina
|
||||
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
|
||||
# Clipboard
|
||||
agent-browser clipboard read # Read text from clipboard
|
||||
agent-browser clipboard write "text" # Write text to clipboard
|
||||
agent-browser clipboard copy # Copy current selection
|
||||
agent-browser clipboard paste # Paste from clipboard
|
||||
|
||||
# Dialogs (alert, confirm, prompt, beforeunload)
|
||||
agent-browser dialog accept # Accept dialog
|
||||
agent-browser dialog accept "input" # Accept prompt dialog with text
|
||||
agent-browser dialog dismiss # Dismiss/cancel dialog
|
||||
agent-browser dialog status # Check if dialog is open
|
||||
|
||||
# Diff (compare page states)
|
||||
agent-browser diff snapshot # Compare current vs last snapshot
|
||||
agent-browser diff screenshot --baseline before.png # Visual pixel diff
|
||||
agent-browser diff url <url1> <url2> # Compare two pages
|
||||
|
||||
# Streaming
|
||||
agent-browser stream enable # Start WebSocket streaming
|
||||
agent-browser stream status # Inspect streaming state
|
||||
agent-browser stream disable # Stop streaming
|
||||
```
|
||||
|
||||
## Batch Execution
|
||||
|
||||
```bash
|
||||
echo '[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["screenshot", "result.png"]
|
||||
]' | agent-browser batch --json
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Option 1: Auth vault (credentials stored encrypted)
|
||||
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
|
||||
agent-browser auth login myapp
|
||||
|
||||
# Option 2: Session name (auto-save/restore cookies + localStorage)
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
agent-browser close # State auto-saved
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
|
||||
|
||||
# Option 3: Persistent profile
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
|
||||
# Option 4: State file
|
||||
agent-browser state save auth.json
|
||||
agent-browser state load auth.json
|
||||
```
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
|
||||
```bash
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
|
||||
## JavaScript Evaluation (eval)
|
||||
|
||||
```bash
|
||||
# Simple expressions
|
||||
agent-browser eval 'document.title'
|
||||
|
||||
# Complex JS: use --stdin with heredoc (RECOMMENDED)
|
||||
agent-browser eval --stdin << 'EVALEOF'
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll("img"))
|
||||
.filter(i => !i.alt)
|
||||
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
|
||||
)
|
||||
EVALEOF
|
||||
|
||||
# Base64 encoding (avoids all shell escaping issues)
|
||||
agent-browser eval -b "$(echo -n 'document.title' | base64)"
|
||||
```
|
||||
|
||||
## Ref Lifecycle
|
||||
|
||||
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
|
||||
|
||||
## Annotated Screenshots (Vision Mode)
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
# Output includes the image path and a legend:
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
## Parallel Sessions
|
||||
|
||||
```bash
|
||||
agent-browser --session site1 open https://site-a.com
|
||||
agent-browser --session site2 open https://site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## Connect to Existing Chrome
|
||||
|
||||
```bash
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser --cdp 9222 snapshot # Explicit CDP port
|
||||
```
|
||||
|
||||
## iOS Simulator (Mobile Safari)
|
||||
|
||||
```bash
|
||||
agent-browser device list
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
agent-browser -p ios snapshot -i
|
||||
agent-browser -p ios tap @e1
|
||||
agent-browser -p ios swipe up
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
```bash
|
||||
agent-browser dashboard install
|
||||
agent-browser dashboard start # Background server on port 4848
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
## Cloud Providers
|
||||
|
||||
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
|
||||
```
|
||||
|
||||
## Electron (LobeHub Desktop)
|
||||
|
||||
### Setup / Teardown
|
||||
|
||||
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
|
||||
|
||||
```bash
|
||||
SCRIPT=".agents/skills/local-testing/scripts/electron-dev.sh"
|
||||
|
||||
# Start Electron dev with CDP (idempotent — skips if already running)
|
||||
$SCRIPT start
|
||||
|
||||
# Check if Electron is running and CDP is reachable
|
||||
$SCRIPT status
|
||||
|
||||
# Kill all Electron-related processes (main + helper + vite)
|
||||
$SCRIPT stop
|
||||
|
||||
# Force fresh restart
|
||||
$SCRIPT restart
|
||||
```
|
||||
|
||||
After `start` succeeds, connect with: `agent-browser --cdp 9222 snapshot -i`
|
||||
|
||||
**Always run `$SCRIPT stop` when done testing** — `pkill -f "Electron"` alone won't catch all helper processes.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------- | ----------------------- | ---------------------------------------- |
|
||||
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
|
||||
| `ELECTRON_LOG` | `/tmp/electron-dev.log` | Electron process log |
|
||||
| `ELECTRON_WAIT_S` | `60` | Max seconds to wait for Electron process |
|
||||
| `RENDERER_WAIT_S` | `60` | Max seconds to wait for SPA to load |
|
||||
|
||||
### LobeHub-Specific Patterns
|
||||
|
||||
#### Access Zustand Store State
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
return JSON.stringify({
|
||||
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
|
||||
activeAgent: chat.activeAgentId,
|
||||
activeTopic: chat.activeTopicId,
|
||||
});
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Find and Use the Chat Input
|
||||
|
||||
```bash
|
||||
# The chat input is contenteditable — must use -C flag
|
||||
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
|
||||
|
||||
agent-browser --cdp 9222 click @e48
|
||||
agent-browser --cdp 9222 type @e48 "Hello world"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
```
|
||||
|
||||
#### Wait for Agent to Complete
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
var running = ops.filter(function(o) { return o.status === 'running'; });
|
||||
return running.length === 0 ? 'done' : 'running: ' + running.length;
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Install Error Interceptor
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
window.__CAPTURED_ERRORS = [];
|
||||
var orig = console.error;
|
||||
console.error = function() {
|
||||
var msg = Array.from(arguments).map(function(a) {
|
||||
if (a instanceof Error) return a.message;
|
||||
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
||||
}).join(' ');
|
||||
window.__CAPTURED_ERRORS.push(msg);
|
||||
orig.apply(console, arguments);
|
||||
};
|
||||
return 'installed';
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# Later, check captured errors:
|
||||
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
|
||||
```
|
||||
|
||||
## Chrome / Web Apps
|
||||
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
"<URL>" &
|
||||
sleep 5
|
||||
agent-browser --cdp 9222 snapshot -i
|
||||
|
||||
# Or auto-discover running Chrome with remote debugging
|
||||
agent-browser --auto-connect snapshot -i
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 2: osascript (Native macOS App Bot Testing)
|
||||
|
||||
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. Works with any app that supports macOS Accessibility, no CDP or Chromium needed.
|
||||
|
||||
The pattern is the same for every platform:
|
||||
|
||||
1. **Activate** the app (`tell application "X" to activate`)
|
||||
2. **Navigate** to a channel/chat (Quick Switcher `Cmd+K` or Search `Cmd+F`)
|
||||
3. **Send** a message (clipboard paste `Cmd+V` + Enter)
|
||||
4. **Wait** for the bot response
|
||||
5. **Screenshot** for verification (`screencapture` + `Read` tool)
|
||||
|
||||
## Per-Platform References
|
||||
|
||||
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
|
||||
|
||||
| Platform | Reference | Quick switcher |
|
||||
| ------------- | ------------------------------------------------ | -------------- |
|
||||
| Discord | [reference/discord.md](./reference/discord.md) | `Cmd+K` |
|
||||
| Slack | [reference/slack.md](./reference/slack.md) | `Cmd+K` |
|
||||
| Telegram | [reference/telegram.md](./reference/telegram.md) | `Cmd+F` |
|
||||
| WeChat / 微信 | [reference/wechat.md](./reference/wechat.md) | `Cmd+F` |
|
||||
| Lark / 飞书 | [reference/lark.md](./reference/lark.md) | `Cmd+K` |
|
||||
| QQ | [reference/qq.md](./reference/qq.md) | `Cmd+F` |
|
||||
|
||||
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [reference/osascript-common.md](./reference/osascript-common.md). Read this first if you're new to osascript automation.
|
||||
|
||||
---
|
||||
|
||||
# Scripts
|
||||
|
||||
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
|
||||
| `capture-app-window.sh` | Capture screenshot of a specific app window |
|
||||
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
|
||||
| `record-app-screen.sh` | Record app screen (video + screenshots, start/stop) |
|
||||
| `test-discord-bot.sh` | Send message to Discord bot via osascript |
|
||||
| `test-slack-bot.sh` | Send message to Slack bot via osascript |
|
||||
| `test-telegram-bot.sh` | Send message to Telegram bot via osascript |
|
||||
| `test-wechat-bot.sh` | Send message to WeChat bot via osascript |
|
||||
| `test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
|
||||
| `test-qq-bot.sh` | Send message to QQ bot via osascript |
|
||||
|
||||
### Window Screenshot Utility
|
||||
|
||||
`capture-app-window.sh` captures a screenshot of a specific app window using `screencapture -l <windowID>`. It uses Swift + CGWindowList to find the window by process name, so screenshots work correctly even when the window is on an external monitor or behind other windows.
|
||||
|
||||
```bash
|
||||
# Standalone usage
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Discord" /tmp/discord.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Slack" /tmp/slack.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "WeChat" /tmp/wechat.png
|
||||
```
|
||||
|
||||
All bot test scripts use this utility automatically for their screenshots.
|
||||
|
||||
### Bot Test Scripts
|
||||
|
||||
All bot test scripts share the same interface:
|
||||
|
||||
```bash
|
||||
./scripts/test-<platform>-bot.sh <channel_or_contact> <message> [wait_seconds] [screenshot_path]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Discord — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
|
||||
# Slack — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
|
||||
# Telegram — test a bot by username
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
|
||||
# WeChat — test a bot or send to a contact
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
|
||||
# Lark/飞书 — test a bot in a group chat
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
|
||||
# QQ — test a bot in a group or direct chat
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
|
||||
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
|
||||
|
||||
---
|
||||
|
||||
# Screen Recording
|
||||
|
||||
Record automated demos using `record-app-screen.sh` (start/stop lifecycle, CDP screenshots + ffmpeg assembly). See [references/record-app-screen.md](references/record-app-screen.md) for full documentation.
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
./.agents/skills/local-testing/scripts/record-app-screen.sh start my-demo
|
||||
# ... run automation ...
|
||||
./.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
```
|
||||
|
||||
Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/` (screenshots every 3s).
|
||||
|
||||
---
|
||||
|
||||
# Gotchas
|
||||
|
||||
### agent-browser
|
||||
|
||||
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
|
||||
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
|
||||
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
|
||||
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
|
||||
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
|
||||
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
|
||||
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
|
||||
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
|
||||
|
||||
### Electron-specific
|
||||
|
||||
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
|
||||
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
|
||||
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
|
||||
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
|
||||
|
||||
### osascript
|
||||
|
||||
See [reference/osascript-common.md](./reference/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Discord Bot Testing
|
||||
|
||||
**App name:** `Discord` | **Process name:** `Discord`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Discord
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
sleep 1
|
||||
|
||||
# Open Quick Switcher (Cmd+K) to navigate to a channel
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# The message input is focused after navigating to a channel
|
||||
# Type a message
|
||||
osascript -e 'tell application "System Events" to keystroke "/hello"'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
```
|
||||
|
||||
## Send Long Message (via clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Write a 3000 word essay about space exploration"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Bot Response
|
||||
|
||||
```bash
|
||||
# Wait for bot to respond, then screenshot
|
||||
sleep 10
|
||||
screencapture /tmp/discord-bot-response.png
|
||||
# Read with the Read tool for visual verification
|
||||
```
|
||||
|
||||
## Full Bot Test Example
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# test-discord-bot.sh — Send message and verify bot response
|
||||
|
||||
# 1. Activate Discord and navigate to channel
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 1
|
||||
-- Quick Switcher
|
||||
tell application "System Events" to keystroke "k" using command down
|
||||
delay 0.5
|
||||
tell application "System Events" to keystroke "bot-testing"
|
||||
delay 1
|
||||
tell application "System Events" to key code 36
|
||||
delay 2
|
||||
'
|
||||
|
||||
# 2. Send test message
|
||||
osascript -e '
|
||||
set the clipboard to "!ping"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 3. Wait for response and capture
|
||||
sleep 5
|
||||
screencapture /tmp/discord-test-result.png
|
||||
echo "Screenshot saved to /tmp/discord-test-result.png"
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# Lark / 飞书 Bot Testing
|
||||
|
||||
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Lark (auto-detects Lark or 飞书)
|
||||
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "飞书" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher / Search (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "@MyBot help me with this task"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/lark-bot-response.png
|
||||
```
|
||||
|
||||
## Lark-Specific Notes
|
||||
|
||||
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
|
||||
- Uses `Cmd+K` for quick search (same as Discord/Slack)
|
||||
- Enter sends message by default
|
||||
- Always use clipboard paste for CJK characters
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# osascript Common Patterns
|
||||
|
||||
Shared AppleScript / `osascript` patterns used by all platform bot tests. Read this first, then refer to the per-platform file for app-specific quirks.
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Activate an App
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
```
|
||||
|
||||
### Type Text
|
||||
|
||||
```bash
|
||||
# Type character by character (reliable, but slow for long text)
|
||||
osascript -e 'tell application "System Events" to keystroke "Hello world"'
|
||||
|
||||
# Press Enter
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
|
||||
# Press Tab
|
||||
osascript -e 'tell application "System Events" to key code 48'
|
||||
|
||||
# Press Escape
|
||||
osascript -e 'tell application "System Events" to key code 53'
|
||||
```
|
||||
|
||||
### Paste from Clipboard (fast, for long text)
|
||||
|
||||
```bash
|
||||
# Set clipboard and paste — much faster than keystroke for long messages
|
||||
osascript -e 'set the clipboard to "Your long message here"'
|
||||
osascript -e 'tell application "System Events" to keystroke "v" using command down'
|
||||
```
|
||||
|
||||
Or in one shot:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Your long message here"
|
||||
tell application "System Events" to keystroke "v" using command down
|
||||
'
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```bash
|
||||
# Cmd+K (quick switcher in Discord/Slack)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
|
||||
# Cmd+F (search)
|
||||
osascript -e 'tell application "System Events" to keystroke "f" using command down'
|
||||
|
||||
# Cmd+N (new message/chat)
|
||||
osascript -e 'tell application "System Events" to keystroke "n" using command down'
|
||||
|
||||
# Cmd+Shift+K (example: multi-modifier)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
|
||||
```
|
||||
|
||||
### Click at Position
|
||||
|
||||
```bash
|
||||
# Click at absolute screen coordinates
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
click at {500, 300}
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Get Window Info
|
||||
|
||||
```bash
|
||||
# Get window position and size
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get {position, size} of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Screenshot
|
||||
|
||||
```bash
|
||||
# Full screen
|
||||
screencapture /tmp/screenshot.png
|
||||
|
||||
# Interactive region select
|
||||
screencapture -i /tmp/screenshot.png
|
||||
|
||||
# Specific window (by window ID from CGWindowList)
|
||||
screencapture -l < WINDOW_ID > /tmp/screenshot.png
|
||||
```
|
||||
|
||||
To get window ID for a specific app:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get id of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Read Accessibility Elements
|
||||
|
||||
```bash
|
||||
# Get all UI elements of the frontmost window (can be slow/large)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
entire contents of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
|
||||
# Get a specific element's value
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get value of text field 1 of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
|
||||
|
||||
### Read Screen Text via Clipboard
|
||||
|
||||
For reading the latest message or response from an app:
|
||||
|
||||
```bash
|
||||
# Select all text in the focused area and copy
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "a" using command down
|
||||
keystroke "c" using command down
|
||||
end tell
|
||||
'
|
||||
sleep 0.5
|
||||
# Read clipboard
|
||||
pbpaste
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Bot Testing Workflow
|
||||
|
||||
Regardless of platform, the pattern is:
|
||||
|
||||
```bash
|
||||
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
|
||||
CHANNEL="bot-testing"
|
||||
MESSAGE="Hello bot!"
|
||||
WAIT_SECONDS=10
|
||||
|
||||
# 1. Activate
|
||||
osascript -e "tell application \"$APP_NAME\" to activate"
|
||||
sleep 1
|
||||
|
||||
# 2. Navigate to channel/chat (via Quick Switcher or Search)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
sleep 2
|
||||
|
||||
# 3. Send message
|
||||
osascript -e "set the clipboard to \"$MESSAGE\""
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 4. Wait for bot response
|
||||
sleep "$WAIT_SECONDS"
|
||||
|
||||
# 5. Screenshot for verification
|
||||
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
|
||||
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
|
||||
- **Add `delay`** between actions — apps need time to process UI events
|
||||
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
|
||||
- **Use a dedicated test channel/chat** — avoid polluting real conversations
|
||||
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
|
||||
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
|
||||
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
|
||||
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
|
||||
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
|
||||
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
|
||||
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
|
||||
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
|
||||
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
|
||||
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
|
||||
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
|
||||
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
|
||||
@@ -0,0 +1,62 @@
|
||||
# QQ Bot Testing
|
||||
|
||||
**App name:** `QQ` | **Process name:** `QQ`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "QQ" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for contact/group (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Hello bot!"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/qq-bot-response.png
|
||||
```
|
||||
|
||||
## QQ-Specific Notes
|
||||
|
||||
- Enter sends message by default; Shift+Enter for newlines
|
||||
- Uses `Cmd+F` for search (not `Cmd+K` like Discord/Slack/Lark)
|
||||
- Always use clipboard paste for CJK characters
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# Slack Bot Testing
|
||||
|
||||
**App name:** `Slack` | **Process name:** `Slack`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Slack
|
||||
osascript -e 'tell application "Slack" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# Direct message input (focused after channel nav)
|
||||
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
|
||||
sleep 0.3
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
```
|
||||
|
||||
## Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "A long test message for the bot..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Slash Command Test
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
tell application "System Events"
|
||||
keystroke "/ask What is the meaning of life?"
|
||||
delay 0.5
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/slack-bot-response.png
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# Telegram Bot Testing
|
||||
|
||||
**App name:** `Telegram` | **Process name:** `Telegram`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Telegram
|
||||
osascript -e 'tell application "Telegram" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a bot (Cmd+F or click search)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "MyTestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message to Bot
|
||||
|
||||
```bash
|
||||
# After navigating to bot chat, input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "/start"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Telegram" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Tell me about quantum computing in detail"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/telegram-bot-response.png
|
||||
```
|
||||
|
||||
## Telegram Bot API (programmatic alternative)
|
||||
|
||||
For sending messages directly to the bot's chat without UI:
|
||||
|
||||
```bash
|
||||
# Send message as the bot (for testing webhooks/responses)
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
-d "chat_id=$CHAT_ID&text=test message"
|
||||
|
||||
# Get recent updates
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# WeChat / 微信 Bot Testing
|
||||
|
||||
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
|
||||
|
||||
See [osascript-common.md](./osascript-common.md) for shared patterns.
|
||||
|
||||
## Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate WeChat
|
||||
osascript -e 'tell application "微信" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a contact/bot (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "TestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
## Send Message
|
||||
|
||||
```bash
|
||||
# After navigating to a chat, the input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "Hello bot!"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Send Long Message (clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "微信" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Please help me with this task..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
## Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/wechat-bot-response.png
|
||||
```
|
||||
|
||||
## WeChat-Specific Notes
|
||||
|
||||
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
|
||||
```bash
|
||||
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "WeChat" to activate'
|
||||
```
|
||||
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
|
||||
- For multi-line messages without sending, use **Shift+Enter**:
|
||||
```bash
|
||||
osascript -e 'tell application "System Events" to key code 36 using shift down'
|
||||
```
|
||||
- Always use clipboard paste for CJK characters — `keystroke` mangles non-ASCII
|
||||
|
||||
## Script
|
||||
|
||||
```bash
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# record-app-screen.sh
|
||||
|
||||
General-purpose screen recording tool for the Electron app. Captures CDP screenshots as video frames and gallery snapshots, then assembles into an MP4 on stop.
|
||||
|
||||
## Why CDP Screenshots Instead of ffmpeg Screen Capture
|
||||
|
||||
- **Works on any screen** — CDP screenshots capture the browser viewport directly, so external monitors, Retina scaling, and window positioning are all handled automatically
|
||||
- **No signal handling issues** — ffmpeg-static (npm) produces corrupt MP4 files when killed (missing moov atom). CDP screenshots avoid this entirely
|
||||
- **Consistent output** — Screenshots are resolution-independent and don't require crop coordinate calculations
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start recording (Electron must be running with CDP)
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start [output_name]
|
||||
|
||||
# Stop recording and assemble video
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
|
||||
# Check if recording is active
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh status
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Default | Description |
|
||||
| ------------- | --------------------------- | -------------------------- |
|
||||
| `output_name` | `recording-YYYYMMDD-HHMMSS` | Base name for output files |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | ------- | -------------------------------------- |
|
||||
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
|
||||
| `SCREENSHOT_INTERVAL` | `3` | Seconds between gallery screenshots |
|
||||
| `VIDEO_FRAME_INTERVAL` | `0.5` | Seconds between video frames (\~2 fps) |
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.records/
|
||||
<name>.mp4 # Video assembled from frames (~2 fps)
|
||||
<name>/ # Gallery screenshots (every 3s)
|
||||
0000.png
|
||||
0001.png
|
||||
0002.png
|
||||
...
|
||||
```
|
||||
|
||||
The `.records/` directory is at the project root and is gitignored.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Start
|
||||
|
||||
1. Creates two background loops:
|
||||
- **Video frames** — `agent-browser screenshot` every `VIDEO_FRAME_INTERVAL` seconds into a temp directory (`/tmp/record-frames-XXXXXX/`)
|
||||
- **Gallery screenshots** — `agent-browser screenshot` every `SCREENSHOT_INTERVAL` seconds into `.records/<name>/`
|
||||
2. Saves PIDs and paths to `/tmp/record-app-screen.pids` and `/tmp/record-app-screen.state`
|
||||
|
||||
### Stop
|
||||
|
||||
1. Kills both background loops
|
||||
2. Assembles video frames into MP4 using ffmpeg:
|
||||
```
|
||||
ffmpeg -framerate 2 -i frame_%06d.png -c:v libx264 -crf 23 -pix_fmt yuv420p <output>.mp4
|
||||
```
|
||||
3. Cleans up temp frame directory
|
||||
4. Reports file sizes and paths
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Test Recording
|
||||
|
||||
```bash
|
||||
# Start Electron
|
||||
.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# Start recording
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start my-test
|
||||
|
||||
# Run automation
|
||||
agent-browser --cdp 9222 click @e61
|
||||
agent-browser --cdp 9222 type @e42 "hello"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
sleep 10
|
||||
|
||||
# Stop and get results
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
# → .records/my-test.mp4 + .records/my-test/*.png
|
||||
```
|
||||
|
||||
### Gateway Streaming Demo
|
||||
|
||||
```bash
|
||||
.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# Inject gateway URL
|
||||
agent-browser --cdp 9222 eval --stdin << 'EOF'
|
||||
(function() {
|
||||
var store = window.global_serverConfigStore;
|
||||
store.setState({ serverConfig: { ...store.getState().serverConfig,
|
||||
agentGatewayUrl: 'https://agent-gateway.lobehub.com' } });
|
||||
return 'ready';
|
||||
})()
|
||||
EOF
|
||||
|
||||
# Record
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh start gateway-demo
|
||||
|
||||
# Navigate to agent, send message, wait for completion...
|
||||
# (automation commands here)
|
||||
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh stop
|
||||
open .records/gateway-demo.mp4
|
||||
```
|
||||
|
||||
### Check Active Recording
|
||||
|
||||
```bash
|
||||
.agents/skills/local-testing/scripts/record-app-screen.sh status
|
||||
# [record] Active recording
|
||||
# Frames: 42 captured (running: yes)
|
||||
# Screenshots: 14 captured (running: yes)
|
||||
# Output: .records/my-test.mp4
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **ffmpeg** — For video assembly. Install via `bun add -g ffmpeg-static` or `brew install ffmpeg`
|
||||
- **agent-browser** — For CDP screenshots. Install via `npm i -g agent-browser`
|
||||
- **Electron app running** — With CDP enabled (use `electron-dev.sh start`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| "No active recording found" on stop | PID file was cleaned up. Check if background processes are still running with `ps aux \| grep agent-browser` |
|
||||
| "A recording is already active" | Run `stop` first, or manually clean: `rm /tmp/record-app-screen.pids /tmp/record-app-screen.state` |
|
||||
| Video is 0 bytes | No frames were captured. Ensure Electron is running and CDP port is correct |
|
||||
| Screenshots are blank/white | SPA may not have loaded yet. Wait for `electron-dev.sh` to report "Renderer ready" |
|
||||
| ffmpeg assembly fails | Check `/tmp/ffmpeg-assemble.log`. Ensure ffmpeg is installed and frames exist |
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# capture-app-window.sh — Capture a screenshot of a specific app window
|
||||
#
|
||||
# Uses CGWindowList via Swift to find the window by process name, then
|
||||
# screencapture -l <windowID> to capture only that window.
|
||||
# Falls back to full-screen capture if the window is not found.
|
||||
#
|
||||
# Usage:
|
||||
# ./capture-app-window.sh <process_name> <output_path>
|
||||
#
|
||||
# Arguments:
|
||||
# process_name — The process/owner name as shown in Activity Monitor
|
||||
# (e.g., "Discord", "Slack", "Telegram", "WeChat", "QQ", "Lark")
|
||||
# output_path — Path to save the screenshot (e.g., /tmp/screenshot.png)
|
||||
#
|
||||
# Examples:
|
||||
# ./capture-app-window.sh "Discord" /tmp/discord.png
|
||||
# ./capture-app-window.sh "Slack" /tmp/slack.png
|
||||
# ./capture-app-window.sh "微信" /tmp/wechat.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROCESS="${1:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
OUTPUT="${2:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
|
||||
# Find the CGWindowID for the target process using Swift + CGWindowList
|
||||
# Pass process name via environment variable (swift -e doesn't support -- args)
|
||||
WINDOW_ID=$(TARGET_PROCESS="$PROCESS" swift -e '
|
||||
import Cocoa
|
||||
import Foundation
|
||||
let target = ProcessInfo.processInfo.environment["TARGET_PROCESS"] ?? ""
|
||||
let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String: Any]]
|
||||
for w in windowList {
|
||||
let owner = w["kCGWindowOwnerName"] as? String ?? ""
|
||||
let layer = w["kCGWindowLayer"] as? Int ?? -1
|
||||
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
|
||||
let ww = bounds["Width"] as? Double ?? 0
|
||||
let wh = bounds["Height"] as? Double ?? 0
|
||||
let wid = w["kCGWindowNumber"] as? Int ?? 0
|
||||
// Match process name, normal window layer (0), and reasonable size
|
||||
if owner == target && layer == 0 && ww > 200 && wh > 200 {
|
||||
print(wid)
|
||||
break
|
||||
}
|
||||
}
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
screencapture -l "$WINDOW_ID" -x "$OUTPUT"
|
||||
else
|
||||
echo "[capture] Warning: Could not find window for '$PROCESS', falling back to full screen"
|
||||
screencapture -x "$OUTPUT"
|
||||
fi
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# electron-dev.sh — Manage Electron dev environment for testing
|
||||
#
|
||||
# Usage:
|
||||
# ./electron-dev.sh start # Kill existing, start fresh, wait until ready
|
||||
# ./electron-dev.sh stop # Kill all Electron-related processes
|
||||
# ./electron-dev.sh status # Check if Electron is running and CDP is reachable
|
||||
# ./electron-dev.sh restart # Stop then start
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
echo "[electron-dev] Stopped."
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
do_start
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
status) do_status ;;
|
||||
restart) do_restart ;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# record-app-screen.sh — Record the Electron app window (video + screenshots)
|
||||
#
|
||||
# Captures screenshots via agent-browser (CDP), then assembles into video on stop.
|
||||
# Works on any screen (including external monitors) since it uses CDP, not screen capture.
|
||||
#
|
||||
# Usage:
|
||||
# ./record-app-screen.sh start [output_name] # Begin recording
|
||||
# ./record-app-screen.sh stop # Stop and save
|
||||
# ./record-app-screen.sh status # Check recording state
|
||||
#
|
||||
# Outputs to .records/ directory:
|
||||
# .records/<name>.mp4 — Video assembled from screenshots (~2 fps)
|
||||
# .records/<name>/ — Screenshots every SCREENSHOT_INTERVAL seconds
|
||||
#
|
||||
# Prerequisites:
|
||||
# - ffmpeg installed (bun add -g ffmpeg-static, or brew install ffmpeg)
|
||||
# - agent-browser CLI installed
|
||||
# - Electron app already running with CDP enabled
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# SCREENSHOT_INTERVAL — Seconds between gallery screenshots (default: 3)
|
||||
# VIDEO_FRAME_INTERVAL — Seconds between video frames (default: 0.5)
|
||||
#
|
||||
# Examples:
|
||||
# ./electron-dev.sh start
|
||||
# ./record-app-screen.sh start gateway-demo
|
||||
# # ... run automation via agent-browser ...
|
||||
# ./record-app-screen.sh stop
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
|
||||
RECORDS_DIR="$PROJECT_DIR/.records"
|
||||
PID_FILE="/tmp/record-app-screen.pids"
|
||||
STATE_FILE="/tmp/record-app-screen.state"
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
SCREENSHOT_INTERVAL="${SCREENSHOT_INTERVAL:-3}"
|
||||
VIDEO_FRAME_INTERVAL="${VIDEO_FRAME_INTERVAL:-0.5}"
|
||||
|
||||
AB="agent-browser --cdp $CDP_PORT"
|
||||
|
||||
# ─── Commands ───
|
||||
|
||||
cmd_start() {
|
||||
local output_name="${1:-recording-$(date +%Y%m%d-%H%M%S)}"
|
||||
local output_video="$RECORDS_DIR/${output_name}.mp4"
|
||||
local screenshot_dir="$RECORDS_DIR/${output_name}"
|
||||
local frames_dir
|
||||
frames_dir=$(mktemp -d /tmp/record-frames-XXXXXX)
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
echo "[record] A recording is already active. Run '$0 stop' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RECORDS_DIR" "$screenshot_dir"
|
||||
|
||||
# Video frames loop (~2 fps via agent-browser CDP screenshots)
|
||||
(
|
||||
local idx=0
|
||||
while true; do
|
||||
local fname
|
||||
fname=$(printf "%s/frame_%06d.png" "$frames_dir" "$idx")
|
||||
$AB screenshot "$fname" 2>/dev/null || true
|
||||
idx=$((idx + 1))
|
||||
sleep "$VIDEO_FRAME_INTERVAL"
|
||||
done
|
||||
) &
|
||||
local frames_pid=$!
|
||||
|
||||
# Gallery screenshots loop (every N seconds for human review)
|
||||
(
|
||||
local idx=0
|
||||
while true; do
|
||||
local fname
|
||||
fname=$(printf "%s/%04d.png" "$screenshot_dir" "$idx")
|
||||
$AB screenshot "$fname" 2>/dev/null || true
|
||||
idx=$((idx + 1))
|
||||
sleep "$SCREENSHOT_INTERVAL"
|
||||
done
|
||||
) &
|
||||
local screenshot_pid=$!
|
||||
|
||||
# Save state
|
||||
echo "$frames_pid $screenshot_pid" > "$PID_FILE"
|
||||
echo "$output_video $frames_dir $screenshot_dir" > "$STATE_FILE"
|
||||
|
||||
echo "[record] Started!"
|
||||
echo " Video frames: every ${VIDEO_FRAME_INTERVAL}s (PID $frames_pid)"
|
||||
echo " Screenshots: every ${SCREENSHOT_INTERVAL}s → $screenshot_dir/"
|
||||
echo " Stop with: $0 stop"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
if [ ! -f "$PID_FILE" ] || [ ! -f "$STATE_FILE" ]; then
|
||||
echo "[record] No active recording found."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local frames_pid screenshot_pid
|
||||
read -r frames_pid screenshot_pid < "$PID_FILE"
|
||||
|
||||
local output_video frames_dir screenshot_dir
|
||||
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
|
||||
|
||||
# Stop both capture loops
|
||||
kill "$frames_pid" 2>/dev/null || true
|
||||
kill "$screenshot_pid" 2>/dev/null || true
|
||||
wait "$frames_pid" 2>/dev/null || true
|
||||
wait "$screenshot_pid" 2>/dev/null || true
|
||||
|
||||
# Assemble frames into video
|
||||
local frame_count
|
||||
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$frame_count" -gt 0 ]; then
|
||||
echo "[record] Assembling $frame_count frames into video..."
|
||||
ffmpeg -y -framerate 2 -i "$frames_dir/frame_%06d.png" \
|
||||
-c:v libx264 -crf 23 -pix_fmt yuv420p -an \
|
||||
"$output_video" > /tmp/ffmpeg-assemble.log 2>&1
|
||||
|
||||
if [ ! -s "$output_video" ]; then
|
||||
echo " [warn] Video assembly failed. Check /tmp/ffmpeg-assemble.log"
|
||||
echo " Frames preserved in: $frames_dir/"
|
||||
fi
|
||||
else
|
||||
echo " [warn] No frames captured."
|
||||
fi
|
||||
|
||||
rm -rf "$frames_dir" 2>/dev/null
|
||||
rm -f "$PID_FILE" "$STATE_FILE"
|
||||
|
||||
local video_size screenshot_count
|
||||
video_size=$(ls -lh "$output_video" 2>/dev/null | awk '{print $5}' || echo "?")
|
||||
screenshot_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
|
||||
echo "[record] Stopped!"
|
||||
echo " Video: $output_video ($video_size)"
|
||||
echo " Screenshots: ${screenshot_count} files in $screenshot_dir/"
|
||||
echo " Play: open $output_video"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "[record] No active recording."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local frames_pid screenshot_pid
|
||||
read -r frames_pid screenshot_pid < "$PID_FILE"
|
||||
|
||||
local frames_ok="no" screenshot_ok="no"
|
||||
kill -0 "$frames_pid" 2>/dev/null && frames_ok="yes"
|
||||
kill -0 "$screenshot_pid" 2>/dev/null && screenshot_ok="yes"
|
||||
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
local output_video frames_dir screenshot_dir
|
||||
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
|
||||
local frame_count ss_count
|
||||
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
ss_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||
echo "[record] Active recording"
|
||||
echo " Frames: $frame_count captured (running: $frames_ok)"
|
||||
echo " Screenshots: $ss_count captured (running: $screenshot_ok)"
|
||||
echo " Output: $output_video"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Main ───
|
||||
|
||||
case "${1:-}" in
|
||||
start) shift; cmd_start "$@" ;;
|
||||
stop) cmd_stop ;;
|
||||
status) cmd_status ;;
|
||||
*)
|
||||
echo "Usage: $0 {start [name] | stop | status}"
|
||||
echo ""
|
||||
echo " start [name] Start recording (default: recording-YYYYMMDD-HHMMSS)"
|
||||
echo " stop Stop recording and save outputs"
|
||||
echo " status Check if recording is active"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-discord-bot.sh — Send a message to a Discord bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/discord-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Discord desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-discord-bot.sh "general" "Hello bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/discord-bot-test.png}"
|
||||
|
||||
APP="Discord"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-lark-bot.sh — Send a message to a Lark/Feishu bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# chat — Chat or contact name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/lark-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Lark (飞书) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Lark" or "飞书" depending on version/locale
|
||||
# - Uses Cmd+K to open search/quick switcher
|
||||
# - Enter sends message by default
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-lark-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-lark-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-lark-bot.sh "MyBot" "Help me summarize this" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHAT="${1:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/lark-bot-test.png}"
|
||||
|
||||
# Detect app name — "Lark" or "飞书"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Lark" to name' &>/dev/null; then
|
||||
APP="Lark"
|
||||
elif osascript -e 'tell application "飞书" to name' &>/dev/null; then
|
||||
APP="飞书"
|
||||
else
|
||||
echo "[error] Lark/飞书 app not found. Install Lark or 飞书."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for chat: $CHAT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher / Search (Cmd+K)
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for chat name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CHAT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-qq-bot.sh — Send a message to a QQ bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact, group, or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/qq-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - QQ desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name is "QQ"
|
||||
# - Uses Cmd+F to open search
|
||||
# - Enter sends message by default; Shift+Enter for newlines
|
||||
# - Uses clipboard paste for CJK character support
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-qq-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-qq-bot.sh "bot-testing" "Hello bot" 30
|
||||
# ./scripts/test-qq-bot.sh "MyBot" "/help" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/qq-bot-test.png}"
|
||||
|
||||
APP="QQ"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-slack-bot.sh — Send a message to a Slack bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send (e.g., "@mybot hello" or "/ask question")
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/slack-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Slack desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
# ./scripts/test-slack-bot.sh "general" "Hey bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/slack-bot-test.png}"
|
||||
|
||||
APP="Slack"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-telegram-bot.sh — Send a message to a Telegram bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# bot_or_chat — Bot username or chat name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/telegram-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Telegram desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Telegram" or "Telegram Desktop" depending on installation
|
||||
# - Uses Cmd+F to search for the bot, then Enter to open the chat
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "Hello bot" 30
|
||||
# ./scripts/test-telegram-bot.sh "GPTBot" "/ask What is AI?" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BOT="${1:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/telegram-bot-test.png}"
|
||||
|
||||
# Detect app name — "Telegram" or "Telegram Desktop"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Telegram" to name' &>/dev/null; then
|
||||
APP="Telegram"
|
||||
elif osascript -e 'tell application "Telegram Desktop" to name' &>/dev/null; then
|
||||
APP="Telegram Desktop"
|
||||
else
|
||||
echo "[error] Telegram app not found. Install Telegram or Telegram Desktop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for: $BOT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Escape first to clear any existing state)
|
||||
key code 53 -- Escape
|
||||
delay 0.3
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$BOT"'"
|
||||
delay 2
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-wechat-bot.sh — Send a message to a WeChat bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/wechat-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - WeChat (微信) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "微信" or "WeChat" depending on system language
|
||||
# - WeChat sends on Enter by default; use Shift+Enter for newlines
|
||||
# - For Chinese text, always uses clipboard paste (keystroke can't handle CJK)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-wechat-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
# ./scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/wechat-bot-test.png}"
|
||||
|
||||
# Detect app name — "微信" or "WeChat"
|
||||
APP=""
|
||||
if osascript -e 'tell application "微信" to name' &>/dev/null; then
|
||||
APP="微信"
|
||||
elif osascript -e 'tell application "WeChat" to name' &>/dev/null; then
|
||||
APP="WeChat"
|
||||
else
|
||||
echo "[error] WeChat app not found. Install 微信 (WeChat)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
# Always use clipboard paste — keystroke can't handle CJK or special characters
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
|
||||
user_invocable: true
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: upstash-workflow-testing
|
||||
description: Local testing guide for Upstash Workflow endpoints via the QStash dev server. Use when verifying workflow handlers end-to-end, debugging why a workflow step isn't firing, inspecting step-level logs and outputs, or triggering a dry-run from curl. Triggers on 'test workflow locally', 'smoke test workflow', 'qstash dev server', 'workflow dry run', 'debug workflow step'.
|
||||
---
|
||||
|
||||
# Upstash Workflow Local Testing
|
||||
|
||||
How to trigger, observe, and debug Upstash Workflow endpoints against the local QStash dev server — **without** writing unit tests.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Workflow endpoints reject raw curl (signature verification). To test locally:
|
||||
|
||||
1. **Publish via QStash dev server** at `localhost:8080`, not directly to your handler
|
||||
2. **Query workflow logs** via `/v2/workflows/logs?workflowRunId=...` — NOT `/v2/events` (events only lists direct publishes, not workflow-internal step publishes)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The `.env` file already ships dev defaults:
|
||||
|
||||
```bash
|
||||
QSTASH_URL="http://localhost:8080"
|
||||
QSTASH_TOKEN="eyJVc2VySUQiOiJkZWZhdWx0VXNlciIs..." # dev default token
|
||||
QSTASH_CURRENT_SIGNING_KEY="sig_..."
|
||||
QSTASH_NEXT_SIGNING_KEY="sig_..."
|
||||
```
|
||||
|
||||
Dev startup (`bun run dev`) boots both the Next.js server and a local QStash dev server. Verify:
|
||||
|
||||
```bash
|
||||
lsof -i :3011 # Next.js
|
||||
lsof -i :8080 # QStash dev server
|
||||
```
|
||||
|
||||
If QStash isn't up: `brew install upstash/qstash/qstash-cli && qstash dev` (or check the dev startup script).
|
||||
|
||||
## 1. Trigger a workflow
|
||||
|
||||
Publish to QStash dev server, which signs + forwards to your handler:
|
||||
|
||||
```bash
|
||||
TOKEN="$(grep '^QSTASH_TOKEN=' .env | cut -d '"' -f2)"
|
||||
TARGET="http://localhost:3011/api/workflows/memory-user-memory/cron/hourly"
|
||||
|
||||
curl -X POST "http://localhost:8080/v2/publish/$TARGET" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"dryRun": true, "baseUrl": "http://localhost:3011"}'
|
||||
# → {"messageId":"msg_..."}
|
||||
```
|
||||
|
||||
For dry-run, always pass `"dryRun": true` in the body. The handler should return stats without triggering downstream L2/L3 pipelines.
|
||||
|
||||
## 2. Observe execution
|
||||
|
||||
### Workflow logs (authoritative source)
|
||||
|
||||
```bash
|
||||
# List recent runs (all workflows)
|
||||
curl -s "http://localhost:8080/v2/workflows/logs" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '.runs[] | {id: .workflowRunId, url: .workflowUrl, state: .workflowState}'
|
||||
|
||||
# Inspect a specific run
|
||||
WFR="wfr_xxxxxxxxxxxxxxxxx"
|
||||
curl -s "http://localhost:8080/v2/workflows/logs?workflowRunId=$WFR" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '.runs[0].steps'
|
||||
```
|
||||
|
||||
Each step entry includes:
|
||||
|
||||
- `stepName` — what you passed to `context.run('<name>', ...)`
|
||||
- `stepType` — `Initial` | `Run` | `Call` | `Invoke` | `SleepFor` | etc.
|
||||
- `state` — `STEP_SUCCESS` | `STEP_FAILED` | `STEP_RETRY`
|
||||
- `out` — JSON-serialized return value of the step (base64 in some fields)
|
||||
- `messageId` — underlying QStash message for that step
|
||||
|
||||
Run-level `workflowState`:
|
||||
|
||||
- `RUN_SUCCESS` — all steps completed
|
||||
- `RUN_FAILED` — a step hit max retries
|
||||
- `RUN_CANCELED` — explicitly canceled
|
||||
|
||||
### Events (direct-publish only — NOT step-level)
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/v2/events?count=50" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '.events[] | {state, url, messageId}'
|
||||
```
|
||||
|
||||
`/v2/events` only shows messages you publish to QStash directly (the initial trigger, plus any `client.trigger(...)` calls from inside a `context.run`). It does **NOT** show internal workflow-step messages that `serve()` publishes to itself — for those, use `/v2/workflows/logs`.
|
||||
|
||||
If you trigger pipeline A → B and only see A's messages in `/v2/events`, that usually means A's handler published correctly but B hasn't been inspected by workflow logs yet. Query `/v2/workflows/logs` for B's workflowRunId instead.
|
||||
|
||||
## 3. Common failure modes
|
||||
|
||||
### a. 500 "Upstash-Signature header is not passed"
|
||||
|
||||
You curl'd the handler directly. Publish via `http://localhost:8080/v2/publish/<target>` instead.
|
||||
|
||||
### b. Handler runs but no downstream workflow fires
|
||||
|
||||
The `qstashClient` passed to `serve()` or used by your `triggerXxx` helper probably doesn't honor `QSTASH_URL`. **Both clients must point at the dev server.**
|
||||
|
||||
`@upstash/qstash`'s `Client` uses **`baseUrl`** in the config object (NOT `url`) and also reads `QSTASH_URL` from env automatically:
|
||||
|
||||
```ts
|
||||
// ✅ Correct
|
||||
new Client({ token, baseUrl: process.env.QSTASH_URL });
|
||||
|
||||
// ⚠️ Works too (env var fallback) but explicit is safer
|
||||
new Client({ token });
|
||||
```
|
||||
|
||||
`@upstash/workflow`'s `Client` — used by `MemoryExtractionWorkflowService` and similar trigger helpers — forwards to the same QStash client internally.
|
||||
|
||||
### c. `triggerXxx()` returns `{workflowRunId}` but `/v2/events` shows nothing
|
||||
|
||||
`/v2/events` only lists direct publishes. A `client.trigger()` call publishes to QStash's workflow API, which creates a run log entry (visible via `/v2/workflows/logs`) plus its own initial QStash message. Always cross-check with `/v2/workflows/logs` before concluding the trigger failed.
|
||||
|
||||
### d. Dry-run path still cascades to L2
|
||||
|
||||
Means the handler read `dryRun` from the wrong field. For our codebase the convention is to put `dryRun: true` at the **top level** of the body; the L1 handler reads it off `context.requestPayload` directly (not via `normalizeMemoryExtractionPayload`, which strips unknown fields). When in doubt, `appendFileSync('/tmp/<wf>-debug.log', ...)` inside the handler to log the exact payload received.
|
||||
|
||||
### e. You need to see handler logs but can't access dev server stdout
|
||||
|
||||
Dev is usually started in the background. When you can't tail stdout, drop a **temporary** file logger into the handler:
|
||||
|
||||
```ts
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
appendFileSync('/tmp/wf-debug.log', `[${new Date().toISOString()}] <message>\n`);
|
||||
```
|
||||
|
||||
Delete before committing. Also consider `verbose: true` on the `serve()` options — that routes @upstash/workflow's internal tracing to console (which, again, you need stdout access for).
|
||||
|
||||
## 4. End-to-end smoke recipes
|
||||
|
||||
### Dry-run the entire hourly cron dispatcher
|
||||
|
||||
```bash
|
||||
TOKEN=$(grep '^QSTASH_TOKEN=' .env | cut -d '"' -f2)
|
||||
TARGET='http://localhost:3011/api/workflows/memory-user-memory/cron/hourly'
|
||||
MSG=$(curl -s -X POST "http://localhost:8080/v2/publish/$TARGET" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"dryRun": true, "baseUrl": "http://localhost:3011"}' \
|
||||
| jq -r .messageId)
|
||||
|
||||
# Follow the cron/hourly run to completion (polls until RUN_SUCCESS or RUN_FAILED)
|
||||
while :; do
|
||||
STATE=$(curl -s "http://localhost:8080/v2/workflows/logs" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
| jq -r --arg url "$TARGET" '.runs[] | select(.workflowUrl == $url) | .workflowState' | head -1)
|
||||
echo "state: $STATE"
|
||||
[[ "$STATE" == "RUN_SUCCESS" || "$STATE" == "RUN_FAILED" ]] && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
Expected on success: two child workflow runs appear in `/v2/workflows/logs` — one at `/topics/process-users`, one at `/persona/process-users`. Each should also reach `RUN_SUCCESS` in dry-run (L1 returns stats; no L2 triggered).
|
||||
|
||||
### Directly target a single L1 (skip the cron dispatcher)
|
||||
|
||||
```bash
|
||||
TARGET='http://localhost:3011/api/workflows/memory-user-memory/topics/process-users'
|
||||
curl -X POST "http://localhost:8080/v2/publish/$TARGET" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"dryRun": true, "baseUrl": "http://localhost:3011", "mode": "workflow"}'
|
||||
```
|
||||
|
||||
Then query logs for that workflow run — should complete in 1–2 steps with stats in the final step's `out`.
|
||||
|
||||
## 5. What NOT to do
|
||||
|
||||
- ❌ Unit-testing the handler by constructing a fake `WorkflowContext`. The workflow runtime does step caching, replay, and QStash round-trips that you can't realistically mock. Integration via QStash dev server is faster and more accurate.
|
||||
- ❌ Bypassing signature verification by clearing `QSTASH_*_SIGNING_KEY` env. Dev QStash signs requests — leaving verification on catches misconfigured receivers.
|
||||
- ❌ Relying on `/v2/events` as the full picture of a workflow run. Use `/v2/workflows/logs` for step-level truth.
|
||||
|
||||
## References
|
||||
|
||||
- Upstash QStash local dev: <https://upstash.com/docs/qstash/howto/local-development>
|
||||
- Workflow basics (serve/context/run): <https://upstash.com/docs/workflow/basics/context>
|
||||
- Related skill: `upstash-workflow` (implementation patterns)
|
||||
@@ -71,15 +71,18 @@ internal_createTopic: async (params) => {
|
||||
**Actions:**
|
||||
|
||||
- Public: `createTopic`, `sendMessage`
|
||||
|
||||
- Internal: `internal_createTopic`, `internal_updateMessageContent`
|
||||
|
||||
- Dispatch: `internal_dispatchTopic`
|
||||
- Toggle: `internal_toggleMessageLoading`
|
||||
**State:**
|
||||
|
||||
**State:**
|
||||
- ID arrays: `topicEditingIds`
|
||||
|
||||
- ID arrays: `messageLoadingIds`, `topicEditingIds`
|
||||
- Maps: `topicMaps`, `messagesMap`
|
||||
|
||||
- Active: `activeTopicId`
|
||||
|
||||
- Init flags: `topicsInit`
|
||||
|
||||
## Detailed Guides
|
||||
|
||||
@@ -30,16 +30,13 @@ internal_createMessage: async (message, context) => {
|
||||
let tempId = context?.tempMessageId;
|
||||
if (!tempId) {
|
||||
tempId = internal_createTmpMessage(message);
|
||||
internal_toggleMessageLoading(true, tempId);
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await messageService.createMessage(message);
|
||||
await refreshMessages();
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
return id;
|
||||
} catch (e) {
|
||||
internal_toggleMessageLoading(false, tempId);
|
||||
internal_dispatchMessage({
|
||||
id: tempId,
|
||||
type: 'updateMessage',
|
||||
|
||||
@@ -162,11 +162,15 @@ describe('ModuleName', () => {
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
✅ test: add unit tests for [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `✅ test: add unit tests for [module-name]`
|
||||
@@ -198,6 +202,7 @@ describe('ModuleName', () => {
|
||||
- Test approach: [brief description]
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -77,20 +77,24 @@ Create `e2e/src/features/{module-name}/README.md` with:
|
||||
# {Module} 模块 E2E 测试覆盖
|
||||
|
||||
## 模块概述
|
||||
|
||||
**路由**: `/module`, `/module/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 功能分组名称
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | -------- |
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | ------------- |
|
||||
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
## 测试执行
|
||||
|
||||
## 已知问题
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
@@ -228,7 +232,7 @@ const testId = pickle.tags.find(
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
```
|
||||
@@ -300,11 +304,15 @@ HEADLESS=true BASE_URL=http://localhost:3006 \
|
||||
### 10. Create Pull Request
|
||||
|
||||
- Branch name: `test/e2e-{module-name}`
|
||||
|
||||
- Commit message format:
|
||||
|
||||
```
|
||||
✅ test: add E2E tests for {module-name}
|
||||
```
|
||||
|
||||
- PR title: `✅ test: add E2E tests for {module-name}`
|
||||
|
||||
- PR body template:
|
||||
|
||||
````markdown
|
||||
|
||||
@@ -36,6 +36,7 @@ If you detect any leaked secrets, respond IMMEDIATELY with:
|
||||
⚠️ **Security Warning**: Your comment appears to contain sensitive information (API keys, secrets, or credentials).
|
||||
|
||||
**Please delete your comment immediately** to protect your account security, then:
|
||||
|
||||
1. Rotate/regenerate any exposed credentials
|
||||
2. Re-post your question with secrets redacted (e.g., `AUTH_SECRET=***`)
|
||||
|
||||
@@ -73,12 +74,17 @@ Look for the "Troubleshooting" or "FAQ" section in the migration docs and match
|
||||
## Response Guidelines
|
||||
|
||||
1. **Be helpful and friendly** - Users are often frustrated when migration doesn't work
|
||||
|
||||
2. **Be specific** - Provide exact commands or configuration examples
|
||||
|
||||
3. **Reference documentation** - Point users to relevant docs sections
|
||||
|
||||
4. **Ask for logs** - If the issue is unclear, ask for Docker logs:
|
||||
|
||||
```bash
|
||||
docker logs <container_name> 2>&1 | tail -100
|
||||
```
|
||||
|
||||
5. **One issue at a time** - Focus on solving one problem before moving to the next
|
||||
|
||||
## Response Format
|
||||
@@ -90,6 +96,7 @@ Use this format for your responses:
|
||||
|
||||
[If missing information]
|
||||
To help you effectively, please provide:
|
||||
|
||||
- [List missing items]
|
||||
|
||||
[If you can help]
|
||||
@@ -102,6 +109,7 @@ Based on your description, here's what I suggest:
|
||||
|
||||
[If the issue is complex or unknown]
|
||||
This issue needs further investigation. I've notified the team. In the meantime, please:
|
||||
|
||||
1. [Any immediate steps they can try]
|
||||
2. Share your Docker logs if you haven't already
|
||||
```
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
Quick reference for assigning issues based on labels.
|
||||
@@ -28,7 +28,7 @@ Quick reference for assigning issues based on labels.
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------------------- |
|
||||
| `platform:mobile` | @sudongyuer | React Native mobile app |
|
||||
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
|
||||
| `platform:desktop` | @Innei | Electron desktop client, build system |
|
||||
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
|
||||
|
||||
### Feature Labels (feature:\*)
|
||||
@@ -60,6 +60,9 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:group-chat` | @arvinxx | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
| `feature:im-integration` | @rdmclin2 | IM and bot integration (Slack, Discord, etc.) |
|
||||
| `feature:agent-builder` | @ONLY-yours | Agent builder |
|
||||
| `feature:schedule-task` | @ONLY-yours | Schedule task |
|
||||
| `feature:subscription` | @tcmonster | Subscription and billing |
|
||||
| `feature:refund` | @tcmonster | Refund requests |
|
||||
| `feature:recharge` | @tcmonster | Recharge and payment |
|
||||
@@ -125,18 +128,18 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**Single owner:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@username - This is a [feature/component] issue. Please take a look.
|
||||
```
|
||||
|
||||
**Multiple owners:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@primary @secondary - This involves [features]. Please coordinate.
|
||||
```
|
||||
|
||||
**High priority:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@owner @arvinxx - High priority [feature] issue.
|
||||
```
|
||||
|
||||
@@ -72,11 +72,15 @@ Module granularity examples:
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
🌐 chore: translate non-English comments to English in [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
|
||||
@@ -100,6 +104,7 @@ Module granularity examples:
|
||||
`[module-path]`
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -408,3 +408,12 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
|
||||
# When this key is set, Klavis integration will be automatically enabled
|
||||
# KLAVIS_API_KEY=your_klavis_api_key_here
|
||||
|
||||
# #######################################
|
||||
# #### Message Gateway (IM Integration) ##
|
||||
# #######################################
|
||||
|
||||
# URL of the message-gateway Cloudflare Worker for unified IM platform connection management
|
||||
# When set, LobeHub delegates all platform connections to the external gateway
|
||||
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
|
||||
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
@@ -9,16 +9,10 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Setup Environment
|
||||
description: Setup Node.js, pnpm (install) and Bun (script runner) for workflows
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: false
|
||||
default: '24.11.1'
|
||||
package-manager-cache:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: ${{ inputs.package-manager-cache }}
|
||||
@@ -0,0 +1,13 @@
|
||||
AmAzing129
|
||||
arvinxx
|
||||
canisminor1990
|
||||
ilimei
|
||||
Innei
|
||||
lobehubbot
|
||||
nekomeowww
|
||||
ONLY-yours
|
||||
rdmclin2
|
||||
rivertwilight
|
||||
sudongyuer
|
||||
tcmonster
|
||||
tjx666
|
||||
@@ -3,7 +3,7 @@ name: Daily i18n Update
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Add permissions configuration
|
||||
permissions:
|
||||
@@ -25,13 +25,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Update i18n
|
||||
run: bun run i18n
|
||||
|
||||
@@ -74,22 +74,13 @@ jobs:
|
||||
git checkout main
|
||||
git pull --rebase origin main
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup environment
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Resolve patch version (patch bump)
|
||||
id: patch-version
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Bundle Analyzer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
bundle-analyzer:
|
||||
@@ -20,19 +19,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -29,11 +29,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -18,6 +18,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
ISSUE_AUTHOR="${{ github.event.issue.user.login }}"
|
||||
if grep -iq "^${ISSUE_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy triage prompts
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
@@ -62,7 +72,7 @@ jobs:
|
||||
**IMPORTANT**:
|
||||
- Follow ALL steps in the issue-triage.md guide
|
||||
- Apply labels according to the guide's rules
|
||||
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
|
||||
- ${{ steps.check-team.outputs.is_team == 'true' && 'The issue author is a team member. Do NOT post any @mention comment.' || 'Post a mention comment to the appropriate team member(s) based on team-assignment.md' }}
|
||||
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
|
||||
|
||||
**Start the triage process now.**
|
||||
|
||||
@@ -21,7 +21,18 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
if grep -iq "^${PR_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy prompts
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/pr-assign.md /tmp/claude-prompts/
|
||||
@@ -29,6 +40,7 @@ jobs:
|
||||
cp .claude/prompts/security-rules.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for PR Reviewer Assignment
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -55,5 +55,5 @@ jobs:
|
||||
# Security: Allow only specific safe commands - no gh commands to prevent token exfiltration
|
||||
# These tools are restricted to code analysis and build operations only
|
||||
claude_args: |
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(pnpm run:*),Bash(npm run:*),Bash(npx:*),Bash(bunx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(bunx:*),Bash(pnpm:*),Bash(npm run:*),Bash(npx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--append-system-prompt "$(cat /tmp/claude-prompts/security-rules.md)"
|
||||
|
||||
@@ -61,13 +61,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies (bun)
|
||||
run: bun install
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Auto-closes issues that are duplicates of existing issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
auto-close-duplicates:
|
||||
@@ -17,10 +17,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Auto-close duplicate issues
|
||||
run: bun run .github/scripts/auto-close-duplicates.ts
|
||||
|
||||
@@ -28,9 +28,21 @@ jobs:
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if PR author is maintainer
|
||||
if: github.event.pull_request.merged == true
|
||||
id: maintainer-check
|
||||
run: |
|
||||
if [ -f .github/maintainers.txt ] && grep -qx "${{ github.event.pull_request.user.login }}" .github/maintainers.txt; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true
|
||||
if: github.event.pull_request.merged == true && steps.maintainer-check.outputs.skip != 'true'
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
comment: |
|
||||
|
||||
@@ -6,10 +6,10 @@ on:
|
||||
channel:
|
||||
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
|
||||
required: true
|
||||
default: nightly
|
||||
default: canary
|
||||
type: choice
|
||||
options:
|
||||
- nightly
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
build_macos:
|
||||
@@ -41,7 +41,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -102,18 +101,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -127,8 +118,8 @@ jobs:
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
@@ -193,8 +184,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
@@ -222,17 +213,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -244,8 +228,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -274,12 +258,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -27,15 +27,11 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
@@ -93,29 +89,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
|
||||
- name: Remove China electron mirror from .npmrc
|
||||
shell: bash
|
||||
run: |
|
||||
NPMRC_FILE="./apps/desktop/.npmrc"
|
||||
if [ -f "$NPMRC_FILE" ]; then
|
||||
sed -i.bak '/^electron_mirror=/d; /^electron_builder_binaries_mirror=/d' "$NPMRC_FILE"
|
||||
rm -f "${NPMRC_FILE}.bak"
|
||||
echo "✅ Removed electron mirror config from .npmrc"
|
||||
fi
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
# 设置 package.json 的版本号
|
||||
- name: Set package version
|
||||
@@ -228,12 +205,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -251,13 +224,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Release Desktop Beta
|
||||
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1
|
||||
#
|
||||
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
||||
# 注意: Nightly 版本 (如 v2.1.0-nightly.xxx) 由 release-desktop-nightly.yml 处理
|
||||
# 注意: Nightly 版本已停用,不再参与 Desktop 发布流程
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Beta 版本包含 beta/alpha/rc (nightly 由 release-desktop-nightly.yml 处理)
|
||||
# Beta 版本包含 beta/alpha/rc;nightly 标签已停用
|
||||
if [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_beta=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is a nightly release (handled by release-desktop-nightly.yml)"
|
||||
echo "⏭️ Skipping: $version is a disabled nightly release tag"
|
||||
elif [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
echo "is_beta=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Beta release detected: $version"
|
||||
@@ -62,19 +62,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -168,16 +162,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -195,13 +183,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
name: Calculate Canary Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_notes: ${{ steps.release-notes.outputs.release_notes }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -121,6 +122,66 @@ jobs:
|
||||
echo "✅ Canary version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
- name: Generate canary release notes
|
||||
if: steps.check.outputs.should_build == 'true'
|
||||
id: release-notes
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
previous_canary=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9]+$' | head -n 1)
|
||||
latest_stable=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -n "$previous_canary" ]; then
|
||||
compare_from="$previous_canary"
|
||||
compare_range="${previous_canary}..HEAD"
|
||||
elif [ -n "$latest_stable" ]; then
|
||||
compare_from="$latest_stable"
|
||||
compare_range="${latest_stable}..HEAD"
|
||||
else
|
||||
compare_from="initial commit"
|
||||
compare_range="HEAD"
|
||||
fi
|
||||
|
||||
commit_count=$(git rev-list --count "$compare_range")
|
||||
commits=$(git log --no-merges --pretty='- `%h` %s (%an)' "$compare_range")
|
||||
|
||||
if [ -z "$commits" ]; then
|
||||
commits='- No new commits recorded.'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "release_notes<<EOF"
|
||||
echo "## 🐤 Canary Build — ${TAG}"
|
||||
echo
|
||||
echo "> Automated canary build from \`canary\` branch."
|
||||
echo
|
||||
echo "### Commit Information"
|
||||
echo
|
||||
echo "- Based on changes since \`${compare_from}\`"
|
||||
echo "- Commit count: ${commit_count}"
|
||||
echo
|
||||
printf '%s\n' "$commits"
|
||||
echo
|
||||
echo "### ⚠️ Important Notes"
|
||||
echo
|
||||
echo "- **This is an automated canary build and is NOT intended for production use.**"
|
||||
echo "- Canary builds are triggered by \`build\`/\`fix\`/\`style\` commits on the \`canary\` branch."
|
||||
echo "- May contain **unstable or incomplete changes**. **Use at your own risk.**"
|
||||
echo "- It is strongly recommended to **back up your data** before using a canary build."
|
||||
echo
|
||||
echo "### 📦 Installation"
|
||||
echo
|
||||
echo "Download the appropriate installer for your platform from the assets below."
|
||||
echo
|
||||
echo "| Platform | File |"
|
||||
echo "|----------|------|"
|
||||
echo "| macOS (Apple Silicon) | \`.dmg\` (arm64) |"
|
||||
echo "| macOS (Intel) | \`.dmg\` (x64) |"
|
||||
echo "| Windows | \`.exe\` |"
|
||||
echo "| Linux | \`.AppImage\` / \`.deb\` |"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
@@ -133,19 +194,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -188,6 +243,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -207,6 +263,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -222,6 +279,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -247,16 +305,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -311,28 +363,7 @@ jobs:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated canary build from `canary` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated canary build and is NOT intended for production use.**
|
||||
- Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch.
|
||||
- May contain **unstable or incomplete changes**. **Use at your own risk.**
|
||||
- It is strongly recommended to **back up your data** before using a canary build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
body: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
name: Release Desktop Nightly
|
||||
|
||||
# ============================================
|
||||
# Nightly 自动发版工作流
|
||||
# ============================================
|
||||
# 触发条件:
|
||||
# 1. 定时: 每天 UTC+8 14:00 (UTC 06:00)
|
||||
# 2. 手动触发 (workflow_dispatch)
|
||||
#
|
||||
# 版本策略:
|
||||
# 基于最新 tag 的 minor+1, 格式: X.(Y+1).0-nightly.YYYYMMDDHHMM
|
||||
# 例: 当前 tag v2.0.12 → v2.1.0-nightly.202502091400
|
||||
# 使用精确到分钟的时间戳避免同一天多次触发时 tag 冲突
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force build (skip diff check)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 计算 Nightly 版本号
|
||||
# ============================================
|
||||
calculate-version:
|
||||
name: Calculate Nightly Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
has_changes: ${{ steps.changes.outputs.has_changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for code changes since last nightly
|
||||
id: changes
|
||||
run: |
|
||||
# 手动触发 + force 时跳过 diff 检查
|
||||
if [ "${{ inputs.force }}" == "true" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "🔧 Force build requested, skipping diff check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 查找上一个 nightly tag
|
||||
last_nightly=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' | head -n 1)
|
||||
|
||||
if [ -z "$last_nightly" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "📦 No previous nightly tag found, proceeding with first nightly build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📌 Last nightly tag: $last_nightly"
|
||||
|
||||
# 对比指定目录是否有变更
|
||||
changes=$(git diff --name-only "$last_nightly"..HEAD -- package.json src/ packages/ apps/desktop/)
|
||||
|
||||
if [ -z "$changes" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No code changes since $last_nightly, skipping nightly build"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
change_count=$(echo "$changes" | wc -l | tr -d ' ')
|
||||
echo "✅ ${change_count} file(s) changed since $last_nightly:"
|
||||
echo "$changes" | head -20
|
||||
[ "$change_count" -gt 20 ] && echo " ... and $((change_count - 20)) more"
|
||||
fi
|
||||
|
||||
- name: Calculate nightly version
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: version
|
||||
run: |
|
||||
# 获取最新的 tag (排除 nightly tag)
|
||||
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "❌ No stable tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Latest stable tag: $latest_tag"
|
||||
|
||||
# 去掉 v 前缀
|
||||
base_version="${latest_tag#v}"
|
||||
|
||||
# 解析 major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$base_version"
|
||||
|
||||
# minor + 1, patch 归零
|
||||
new_minor=$((minor + 1))
|
||||
timestamp=$(date -u +"%Y%m%d%H%M")
|
||||
|
||||
version="${major}.${new_minor}.0-nightly.${timestamp}"
|
||||
tag="v${version}"
|
||||
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
echo "✅ Nightly version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
test:
|
||||
name: Code quality check
|
||||
needs: [calculate-version]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
# ============================================
|
||||
# 多平台构建
|
||||
# ============================================
|
||||
build:
|
||||
needs: [calculate-version, test]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} nightly
|
||||
|
||||
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
- name: Clean previous build artifacts (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
sudo rm -rf apps/desktop/release || true
|
||||
sudo rm -rf apps/desktop/dist || true
|
||||
sudo rm -rf /tmp/electron-builder* || true
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
artifact-name: release-${{ matrix.os }}
|
||||
retention-days: 3
|
||||
|
||||
# ============================================
|
||||
# 合并 macOS 多架构 latest-mac.yml 文件
|
||||
# ============================================
|
||||
merge-mac-files:
|
||||
needs: [build]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
retention-days: 1
|
||||
|
||||
# ============================================
|
||||
# 创建 Nightly Release
|
||||
# ============================================
|
||||
publish-release:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish Nightly Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List final artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Create Nightly Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Nightly ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🌙 Nightly Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated nightly build from `main` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated nightly build and is NOT intended for production use.**
|
||||
- Nightly builds are generated from the latest `main` branch and may contain **unstable, untested, or incomplete features**.
|
||||
- **No guarantees** are made regarding stability, data integrity, or backward compatibility.
|
||||
- Bugs, crashes, and breaking changes are expected. **Use at your own risk.**
|
||||
- **Do NOT report bugs** from nightly builds unless you can reproduce them on the latest beta or stable release.
|
||||
- Nightly builds may have **different update channels** — they will not auto-update to/from stable or beta versions.
|
||||
- It is strongly recommended to **back up your data** before using a nightly build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
release/*.zip*
|
||||
release/*.exe*
|
||||
release/*.AppImage
|
||||
release/*.deb*
|
||||
release/*.snap*
|
||||
release/*.rpm*
|
||||
release/*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ============================================
|
||||
# 发布到 S3 更新服务器
|
||||
# ============================================
|
||||
publish-s3:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish to S3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/desktop-publish-s3
|
||||
with:
|
||||
channel: nightly
|
||||
version: ${{ needs.calculate-version.outputs.version }}
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
|
||||
# ============================================
|
||||
# 清理旧的 Nightly Releases (保留最近 7 个)
|
||||
# ============================================
|
||||
cleanup-old-nightlies:
|
||||
needs: [publish-release, publish-s3]
|
||||
name: Cleanup Old Nightly Releases
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Delete old nightly GitHub releases
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const nightlyReleases = releases
|
||||
.filter(r => r.tag_name.includes('-nightly.'))
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const toDelete = nightlyReleases.slice(7);
|
||||
|
||||
for (const release of toDelete) {
|
||||
console.log(`🗑️ Deleting old nightly release: ${release.tag_name}`);
|
||||
|
||||
// Delete the release
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
|
||||
// Delete the tag
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${release.tag_name}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Cleanup complete. Kept ${Math.min(nightlyReleases.length, 7)} nightly releases, deleted ${toDelete.length}.`);
|
||||
|
||||
- name: Cleanup old S3 versions
|
||||
uses: ./.github/actions/desktop-cleanup-s3
|
||||
with:
|
||||
channel: nightly
|
||||
keep-count: '15'
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
@@ -266,16 +266,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Release ModelBank
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- packages/model-bank/**
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ModelBank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
publish:
|
||||
name: Publish ModelBank
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Bump patch version
|
||||
id: version
|
||||
run: |
|
||||
npm version patch --no-git-tag-version --prefix packages/model-bank
|
||||
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance
|
||||
working-directory: packages/model-bank
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Commit version bump
|
||||
env:
|
||||
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
git config user.name "lobehubbot"
|
||||
git config user.email "i@lobehub.com"
|
||||
git add packages/model-bank/package.json
|
||||
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
|
||||
git push
|
||||
@@ -37,19 +37,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
@@ -15,15 +15,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: sync database schema to dbdocs
|
||||
env:
|
||||
DBDOCS_TOKEN: ${{ secrets.DBDOCS_TOKEN }}
|
||||
run: npm run db:visualize
|
||||
run: bun run db:visualize
|
||||
|
||||
+14
-46
@@ -37,19 +37,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Test packages with coverage
|
||||
run: |
|
||||
@@ -111,19 +103,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
|
||||
@@ -146,13 +130,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -181,16 +163,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
@@ -235,20 +209,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test Coverage
|
||||
run: pnpm --filter @lobechat/database test:coverage
|
||||
|
||||
+10
-1
@@ -25,6 +25,9 @@ Desktop.ini
|
||||
*.code-workspace
|
||||
.vscode/sessions.json
|
||||
prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
@@ -52,6 +55,7 @@ bun.lockb
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
public/_spa/
|
||||
public/spa/
|
||||
es/
|
||||
lib/
|
||||
@@ -136,5 +140,10 @@ pnpm-lock.yaml
|
||||
.turbo
|
||||
spaHtmlTemplates.ts
|
||||
|
||||
# Embedded CLI bundle (built at pack time)
|
||||
apps/desktop/resources/bin/lobe-cli.js
|
||||
apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers
|
||||
docs/superpowers/
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/en-US',
|
||||
@@ -27,14 +27,14 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'chatgpt-4o-latest',
|
||||
modelName: 'gpt-5.1-chat-latest',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
markdown: {
|
||||
reference:
|
||||
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf-8'),
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
|
||||
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
|
||||
entryLocale: 'en-US',
|
||||
outputLocales: ['zh-CN'],
|
||||
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
# Lobe Chat - Contributing Guide 🌟
|
||||
# LobeHub - Contributing Guide 🌟
|
||||
|
||||
We're thrilled that you want to contribute to Lobe Chat, the future of communication! 😄
|
||||
We're thrilled that you want to contribute to LobeHub, the future of communication! 😄
|
||||
|
||||
Lobe Chat is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
LobeHub is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -69,11 +69,11 @@ git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
This ensures you're working on the most current version of Lobe Chat. Stay fresh! 💨
|
||||
This ensures you're working on the most current version of LobeHub. Stay fresh! 💨
|
||||
|
||||
## Open a Pull Request
|
||||
|
||||
🚀 Time to share your contribution! Head over to the original Lobe Chat repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
🚀 Time to share your contribution! Head over to the original LobeHub repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
|
||||
## Review and Collaboration
|
||||
|
||||
@@ -81,8 +81,8 @@ This ensures you're working on the most current version of Lobe Chat. Stay fresh
|
||||
|
||||
## Celebrate 🎉
|
||||
|
||||
🎈 Congratulations! Your contribution is now part of Lobe Chat. 🥳
|
||||
🎈 Congratulations! Your contribution is now part of LobeHub. 🥳
|
||||
|
||||
Thank you for making Lobe Chat even more magical. We can't wait to see what you create! 🌠
|
||||
Thank you for making LobeHub even more magical. We can't wait to see what you create! 🌠
|
||||
|
||||
Happy Coding! 🚀🦄
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ COPY --from=base /distroless/ /
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
COPY --from=builder /app/.next/static /app/.next/static
|
||||
# Copy SPA assets (Vite build output)
|
||||
COPY --from=builder /app/public/spa /app/public/spa
|
||||
COPY --from=builder /app/public/_spa /app/public/_spa
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only provide security fixes for the **latest 2.x release**. Older versions (including all 1.x releases) are end-of-life and will not receive patches.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------ | --------- |
|
||||
| 2.x (latest) | ✅ |
|
||||
| 1.x | ❌ |
|
||||
| 0.x | ❌ |
|
||||
|
||||
If you are running a 1.x deployment, we strongly recommend upgrading to the latest 2.x release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lobehub/lobehub/security/advisories/new) tab.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgement**: We aim to respond to all reports within **7 days**.
|
||||
- **Fix**: Confirmed vulnerabilities will be addressed within **30 days**.
|
||||
- **Urgent issues**: If you believe the vulnerability is critical and actively exploitable, you can reach out directly on Discord (`arvinxu`) for faster coordination.
|
||||
|
||||
### What to Include
|
||||
|
||||
A good vulnerability report should include:
|
||||
|
||||
- A clear description of the issue and its potential impact
|
||||
- The affected version (must be the latest 2.x release)
|
||||
- Step-by-step reproduction instructions or a working PoC
|
||||
- Any relevant logs, screenshots, or code references
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Security issues affecting the **latest 2.x release** of LobeHub
|
||||
- Vulnerabilities in the **server-side deployment** (LobeHub Cloud or self-hosted server mode)
|
||||
- Issues that can be exploited **without requiring admin/owner access** to the deployment
|
||||
|
||||
### Out of Scope (Not a Vulnerability)
|
||||
|
||||
The following are considered **by design** or **out of scope** and will not be accepted as vulnerability reports:
|
||||
|
||||
#### 1. End-of-Life Versions
|
||||
|
||||
Any issue that only affects 1.x or earlier versions. This includes but is not limited to the `X-lobe-chat-auth` header mechanism, `webapi` route authentication, and other 1.x-specific architectures that have been completely removed in 2.x.
|
||||
|
||||
#### 2. File Proxy Public Access (`/f/:id`)
|
||||
|
||||
The file proxy endpoint `/f/:id` uses randomly generated, non-enumerable IDs as [capability URLs](https://www.w3.org/TR/capability-urls/). This is a deliberate design choice, similar to how S3 presigned URLs or Google Docs sharing links work. Knowing the URL grants access — this is by design, not an authorization bypass.
|
||||
|
||||
#### 3. User Enumeration on Login Flows
|
||||
|
||||
Endpoints such as `check-user` that indicate whether an account exists are part of the standard login UX. This is a common and intentional pattern used by most modern authentication flows.
|
||||
|
||||
#### 4. Self-Hosted Client-Side API Key Storage
|
||||
|
||||
In self-hosted client-side mode, users configure their own API keys which are stored in the browser's local storage. This is the expected behavior for client-side deployments where the user is both the operator and the consumer.
|
||||
|
||||
#### 5. Issues Requiring Admin or Owner Privileges
|
||||
|
||||
Actions that require administrative access to the deployment (e.g., environment variable configuration, server-side settings) are not considered security vulnerabilities, as the admin is already a trusted party.
|
||||
|
||||
#### 6. Theoretical Attacks Without Practical Impact
|
||||
|
||||
Reports based on theoretical attack scenarios without a working proof of concept against a realistic deployment, or issues that require unlikely preconditions (e.g., physical access to the server, pre-existing compromise of the host system).
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||
- We will credit reporters in the security advisory unless they prefer to remain anonymous.
|
||||
- Please allow us reasonable time to address the issue before any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Primary**: [GitHub Security Advisories](https://github.com/lobehub/lobehub/security/advisories/new)
|
||||
- **Urgent**: Discord — `arvinxu`
|
||||
@@ -15,6 +15,17 @@ LobeHub command-line interface.
|
||||
- To make `lh` available in your shell, run `bun run cli:link`.
|
||||
- After linking, if your shell still cannot find `lh`, run `rehash` in `zsh`.
|
||||
|
||||
## Custom Server URL
|
||||
|
||||
By default the CLI connects to `https://app.lobehub.com`. To point it at a different server (e.g. a local instance):
|
||||
|
||||
| Method | Command | Persistence |
|
||||
| -------------------- | --------------------------------------------------------------- | ----------------------------------- |
|
||||
| Environment variable | `LOBEHUB_SERVER=http://localhost:4000 bun run dev -- <command>` | Current command only |
|
||||
| Login flag | `lh login --server http://localhost:4000` | Saved to `~/.lobehub/settings.json` |
|
||||
|
||||
Priority: `LOBEHUB_SERVER` env var > `settings.json` > default official URL.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
### Install completion for a linked CLI
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.1\-canary.14" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.4" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -83,6 +83,9 @@ Manage agent skills
|
||||
.B session\-group
|
||||
Manage agent session groups
|
||||
.TP
|
||||
.B task
|
||||
Manage agent tasks
|
||||
.TP
|
||||
.B thread
|
||||
Manage message threads
|
||||
.TP
|
||||
@@ -95,6 +98,9 @@ Manage messages
|
||||
.B model
|
||||
Manage AI models
|
||||
.TP
|
||||
.B notify
|
||||
Send a callback message to a topic and trigger the agent to process it
|
||||
.TP
|
||||
.B provider
|
||||
Manage AI providers
|
||||
.TP
|
||||
@@ -112,6 +118,9 @@ View usage statistics
|
||||
.TP
|
||||
.B eval
|
||||
Manage evaluation workflows
|
||||
.TP
|
||||
.B migrate
|
||||
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.14",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -37,6 +37,7 @@
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
|
||||
@@ -39,7 +39,9 @@ async function getAuthAndServer() {
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' (or 'npx -y @lobehub/cli login') first, or set ${CLI_API_KEY_ENV}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+37
-22
@@ -3,29 +3,9 @@ import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
|
||||
|
||||
/**
|
||||
* XOR-obfuscate a payload and encode as Base64.
|
||||
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
|
||||
*/
|
||||
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const dataBytes = new TextEncoder().encode(jsonString);
|
||||
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
|
||||
|
||||
const result = new Uint8Array(dataBytes.length);
|
||||
for (let i = 0; i < dataBytes.length; i++) {
|
||||
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...result));
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
accessToken: string;
|
||||
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
|
||||
/** Headers required for /webapi/* endpoints (Oidc-Auth for authentication) */
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
}
|
||||
@@ -52,8 +32,43 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
|
||||
return {
|
||||
headers: {},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
},
|
||||
device: {
|
||||
listDevices: { query: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,13 +41,18 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAuthInfo: vi.fn(),
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -58,12 +66,12 @@ describe('agent command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockGetAuthInfo.mockResolvedValue({
|
||||
accessToken: 'test-token',
|
||||
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
|
||||
mockGetAgentStreamAuthInfo.mockResolvedValue({
|
||||
headers: { 'Oidc-Auth': 'test-token' },
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
mockStreamAgentEvents.mockResolvedValue(undefined);
|
||||
mockResolveLocalDeviceId.mockReset();
|
||||
for (const method of Object.values(mockTrpcClient.agent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
@@ -74,6 +82,11 @@ describe('agent command', () => {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.device)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -297,7 +310,6 @@ describe('agent command', () => {
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-456',
|
||||
@@ -384,6 +396,186 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --device local as deviceId', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'local-device-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --topic-id and --device local together', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-topic-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appContext: { topicId: 't1' }, deviceId: 'local-device-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass explicit --device id as deviceId', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-explicit-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(mockResolveLocalDeviceId).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'device-remote-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is not found', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'other-device', online: true },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('was not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device cannot be resolved', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue(undefined);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Run 'lh connect' first"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device is offline', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('is not online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is offline', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Bring it online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should pass --json to stream options', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-j',
|
||||
|
||||
@@ -4,8 +4,14 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { getAgentStreamAuthInfo } from '../api/http';
|
||||
import { resolveAgentGatewayUrl } from '../settings';
|
||||
import {
|
||||
replayAgentEvents,
|
||||
streamAgentEvents,
|
||||
streamAgentEventsViaWebSocket,
|
||||
} from '../utils/agentStream';
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
@@ -248,17 +254,24 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-p, --prompt <text>', 'User prompt')
|
||||
.option('-t, --topic-id <id>', 'Reuse an existing topic')
|
||||
.option('--no-auto-start', 'Do not auto-start the agent')
|
||||
.option(
|
||||
'--device <target>',
|
||||
'Target device ID, or use "local" for the current connected device',
|
||||
)
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
|
||||
.option('--sse', 'Force SSE stream instead of WebSocket gateway')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
autoStart?: boolean;
|
||||
device?: string;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
replay?: string;
|
||||
slug?: string;
|
||||
sse?: boolean;
|
||||
topicId?: string;
|
||||
verbose?: boolean;
|
||||
}) => {
|
||||
@@ -285,9 +298,45 @@ export function registerAgentCommand(program: Command) {
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let deviceId: string | undefined;
|
||||
if (options.device !== undefined) {
|
||||
if (options.device === 'local') {
|
||||
deviceId = resolveLocalDeviceId();
|
||||
if (!deviceId) {
|
||||
log.error(
|
||||
"No local device found. Run 'lh connect' first, then retry with --device local.",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
deviceId = options.device;
|
||||
}
|
||||
|
||||
const devices = await client.device.listDevices.query();
|
||||
const matchedDevice = devices.find(
|
||||
(device: { deviceId?: string; online?: boolean }) => device.deviceId === deviceId,
|
||||
);
|
||||
if (!matchedDevice) {
|
||||
log.error(`Device "${deviceId}" was not found. Check 'lh device list' and try again.`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!matchedDevice.online) {
|
||||
log.error(
|
||||
options.device === 'local'
|
||||
? `Local device "${deviceId}" is not online. Reconnect with 'lh connect' and try again.`
|
||||
: `Device "${deviceId}" is not online. Bring it online and try again.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Exec agent to get operationId
|
||||
const input: Record<string, any> = { prompt: options.prompt };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (deviceId) input.deviceId = deviceId;
|
||||
if (options.slug) input.slug = options.slug;
|
||||
if (options.topicId) input.appContext = { topicId: options.topicId };
|
||||
if (options.autoStart === false) input.autoStart = false;
|
||||
@@ -305,14 +354,26 @@ export function registerAgentCommand(program: Command) {
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
|
||||
}
|
||||
|
||||
// 2. Connect to SSE stream
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
|
||||
const { serverUrl, headers } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
if (agentGatewayUrl) {
|
||||
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
token,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+242
-99
@@ -1,39 +1,130 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat'];
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
discord: ['botToken', 'publicKey'],
|
||||
feishu: ['appSecret'],
|
||||
lark: ['appSecret'],
|
||||
slack: ['botToken', 'signingSecret'],
|
||||
telegram: ['botToken'],
|
||||
wechat: ['botToken', 'botId'],
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
return '****';
|
||||
}
|
||||
|
||||
function camelToFlag(name: string): string {
|
||||
return '--' + name.replaceAll(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
/** Extract credential field definitions from a platform schema. */
|
||||
function getCredentialFields(platformDef: any): any[] {
|
||||
const credSchema = (platformDef.schema ?? []).find(
|
||||
(f: any) => f.key === 'credentials' && f.properties,
|
||||
);
|
||||
return credSchema?.properties ?? [];
|
||||
}
|
||||
|
||||
/** Extract credential values from CLI options based on platform schema. */
|
||||
function extractCredentials(
|
||||
platformDef: any,
|
||||
options: Record<string, any>,
|
||||
): { credentials: Record<string, string>; missing: any[] } {
|
||||
const fields = getCredentialFields(platformDef);
|
||||
const credentials: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = options[field.key];
|
||||
if (typeof value === 'string') {
|
||||
credentials[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = fields.filter((f: any) => f.required && !credentials[f.key]);
|
||||
return { credentials, missing };
|
||||
}
|
||||
|
||||
/** Find a bot by ID from the user's bot list. */
|
||||
async function findBot(client: TrpcClient, botId: string) {
|
||||
const bots = await client.agentBotProvider.list.query();
|
||||
const bot = (bots as any[]).find((b: any) => b.id === botId);
|
||||
if (!bot) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return bot;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, (s: string) => string> = {
|
||||
connected: pc.green,
|
||||
disconnected: pc.dim,
|
||||
failed: pc.red,
|
||||
queued: pc.yellow,
|
||||
starting: pc.yellow,
|
||||
unknown: pc.dim,
|
||||
};
|
||||
|
||||
function parseCredentials(
|
||||
platform: string,
|
||||
options: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
const creds: Record<string, string> = {};
|
||||
|
||||
if (options.botToken) creds.botToken = options.botToken;
|
||||
if (options.botId) creds.botId = options.botId;
|
||||
if (options.publicKey) creds.publicKey = options.publicKey;
|
||||
if (options.signingSecret) creds.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) creds.appSecret = options.appSecret;
|
||||
|
||||
return creds;
|
||||
/** Validate a platform ID and return its definition. */
|
||||
async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
const def = (platforms as any[]).find((p: any) => p.id === platformId);
|
||||
if (!def) {
|
||||
const ids = (platforms as any[]).map((p: any) => p.id).join(', ');
|
||||
log.error(`Invalid platform "${platformId}". Must be one of: ${ids}`);
|
||||
log.info('Run `lh bot platforms` to see required credentials for each platform.');
|
||||
process.exit(1);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
const bot = program.command('bot').description('Manage bot integrations');
|
||||
|
||||
// Register message subcommand group
|
||||
registerBotMessageCommands(bot);
|
||||
|
||||
// ── platforms ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('platforms')
|
||||
.description('List supported platforms and their required credentials')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(platforms);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold('Supported platforms:\n'));
|
||||
|
||||
for (const p of platforms as any[]) {
|
||||
console.log(` ${pc.bold(pc.cyan(p.id))}`);
|
||||
if (p.name) console.log(` Name: ${p.name}`);
|
||||
|
||||
const fields = getCredentialFields(p);
|
||||
const required = fields.filter((f: any) => f.required);
|
||||
const optional = fields.filter((f: any) => !f.required);
|
||||
|
||||
if (required.length > 0) {
|
||||
console.log(
|
||||
` Required: ${required.map((f: any) => pc.yellow(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (optional.length > 0) {
|
||||
console.log(
|
||||
` Optional: ${optional.map((f: any) => pc.dim(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
@@ -63,15 +154,20 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
b.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
]);
|
||||
const rows = items.map((b: any) => {
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const colorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
return [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
colorFn(status),
|
||||
b.updatedAt ? timeAgo(b.updatedAt) : pc.dim('-'),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS']);
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
@@ -79,44 +175,62 @@ export function registerBotCommand(program: Command) {
|
||||
bot
|
||||
.command('view <botId>')
|
||||
.description('View bot integration details')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (botId: string, options: { agent: string; json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
.option('--show-credentials', 'Show full credential values (unmasked)')
|
||||
.action(
|
||||
async (botId: string, options: { json?: string | boolean; showCredentials?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(item, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const b = item as any;
|
||||
console.log(pc.bold(`${b.platform} bot`));
|
||||
console.log(pc.dim(`ID: ${b.id}`));
|
||||
console.log(`Application ID: ${b.applicationId}`);
|
||||
console.log(`Status: ${b.enabled ? pc.green('enabled') : pc.dim('disabled')}`);
|
||||
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
console.log();
|
||||
console.log(pc.bold('Credentials:'));
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const masked = val.length > 8 ? val.slice(0, 4) + '****' + val.slice(-4) : '****';
|
||||
console.log(` ${key}: ${masked}`);
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(b, fields);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const statusColorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
|
||||
const credentialLines: string[] = [];
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const display = options.showCredentials ? val : maskValue(val);
|
||||
credentialLines.push(`${pc.dim(key)}: ${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settingsLines: string[] = [];
|
||||
if (b.settings && typeof b.settings === 'object') {
|
||||
for (const [key, value] of Object.entries(b.settings)) {
|
||||
settingsLines.push(`${pc.dim(key)}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
printBoxTable(
|
||||
[
|
||||
{ header: 'Field', key: 'field' },
|
||||
{ header: 'Value', key: 'value' },
|
||||
],
|
||||
[
|
||||
{ field: 'ID', value: b.id || '' },
|
||||
{ field: 'Platform', value: pc.cyan(b.platform || '') },
|
||||
{ field: 'Application ID', value: b.applicationId || '' },
|
||||
{ field: 'Agent ID', value: b.agentId || '' },
|
||||
{ field: 'Status', value: statusColorFn(status) },
|
||||
...(credentialLines.length > 0
|
||||
? [{ field: 'Credentials', value: credentialLines }]
|
||||
: []),
|
||||
...(settingsLines.length > 0 ? [{ field: 'Settings', value: settingsLines }] : []),
|
||||
...(b.createdAt
|
||||
? [{ field: 'Created', value: new Date(b.createdAt).toLocaleString() }]
|
||||
: []),
|
||||
...(b.updatedAt ? [{ field: 'Updated', value: timeAgo(b.updatedAt) }] : []),
|
||||
],
|
||||
`${b.platform} bot`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── add ───────────────────────────────────────────────
|
||||
|
||||
@@ -124,13 +238,18 @@ export function registerBotCommand(program: Command) {
|
||||
.command('add')
|
||||
.description('Add a bot integration to an agent')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
|
||||
.requiredOption('--platform <platform>', 'Platform (run `lh bot platforms` to see options)')
|
||||
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
|
||||
.option('--bot-token <token>', 'Bot token')
|
||||
.option('--bot-token <token>', 'Bot token (Discord, Slack, Telegram)')
|
||||
.option('--bot-id <id>', 'Bot ID (WeChat)')
|
||||
.option('--public-key <key>', 'Public key (Discord)')
|
||||
.option('--signing-secret <secret>', 'Signing secret (Slack)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark/Feishu)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark, Feishu, QQ)')
|
||||
.option('--secret-token <token>', 'Secret token (Telegram)')
|
||||
.option('--webhook-proxy-url <url>', 'Webhook proxy URL (Telegram)')
|
||||
.option('--encrypt-key <key>', 'Encrypt key (Feishu)')
|
||||
.option('--verification-token <token>', 'Verification token (Feishu)')
|
||||
.option('--json', 'Output created bot as JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent: string;
|
||||
@@ -138,34 +257,39 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
json?: boolean;
|
||||
platform: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
}) => {
|
||||
if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
|
||||
log.error(`Invalid platform. Must be one of: ${SUPPORTED_PLATFORMS.join(', ')}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
const platformDef = await resolvePlatform(client, options.platform);
|
||||
|
||||
const credentials = parseCredentials(options.platform, options);
|
||||
const requiredFields = PLATFORM_CREDENTIAL_FIELDS[options.platform] || [];
|
||||
const missing = requiredFields.filter((f) => !credentials[f]);
|
||||
const { credentials, missing } = extractCredentials(platformDef, options);
|
||||
if (missing.length > 0) {
|
||||
log.error(
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f) => '--' + f.replaceAll(/([A-Z])/g, '-$1').toLowerCase()).join(', ')}`,
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f: any) => camelToFlag(f.key)).join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.create.mutate({
|
||||
agentId: options.agent,
|
||||
applicationId: options.appId,
|
||||
credentials,
|
||||
platform: options.platform,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(options.platform)} bot ${pc.bold(r.id || '')}`,
|
||||
@@ -183,6 +307,10 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--public-key <key>', 'New public key')
|
||||
.option('--signing-secret <secret>', 'New signing secret')
|
||||
.option('--app-secret <secret>', 'New app secret')
|
||||
.option('--secret-token <token>', 'New secret token')
|
||||
.option('--webhook-proxy-url <url>', 'New webhook proxy URL')
|
||||
.option('--encrypt-key <key>', 'New encrypt key')
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.action(
|
||||
@@ -193,20 +321,23 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: botId };
|
||||
|
||||
const credentials: Record<string, string> = {};
|
||||
if (options.botToken) credentials.botToken = options.botToken;
|
||||
if (options.botId) credentials.botId = options.botId;
|
||||
if (options.publicKey) credentials.publicKey = options.publicKey;
|
||||
if (options.signingSecret) credentials.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) credentials.appSecret = options.appSecret;
|
||||
const existing = await findBot(client, botId);
|
||||
const platform = options.platform ?? existing.platform;
|
||||
const platformDef = await resolvePlatform(client, platform);
|
||||
|
||||
const { credentials } = extractCredentials(platformDef, options);
|
||||
if (Object.keys(credentials).length > 0) input.credentials = credentials;
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
@@ -217,7 +348,6 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Updated bot ${pc.bold(botId)}`);
|
||||
},
|
||||
@@ -263,28 +393,41 @@ export function registerBotCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Disabled bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
// ── test ───────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('test <botId>')
|
||||
.description('Test bot credentials against the platform API')
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
log.status(`Testing ${b.platform} credentials for ${b.applicationId}...`);
|
||||
|
||||
try {
|
||||
await client.agentBotProvider.testConnection.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Credentials are valid for ${pc.bold(b.platform)} bot`);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Connection test failed';
|
||||
log.error(`Credential test failed: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ── connect ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('connect <botId>')
|
||||
.description('Connect and start a bot')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.action(async (botId: string, options: { agent: string }) => {
|
||||
// First fetch the bot to get platform and applicationId
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
log.status(`Connecting ${b.platform} bot ${b.applicationId}...`);
|
||||
|
||||
const b = item as any;
|
||||
const connectResult = await client.agentBotProvider.connectBot.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBotMessageCommands(bot: Command) {
|
||||
const message = bot
|
||||
.command('message')
|
||||
.description('Send and manage messages on connected platforms');
|
||||
|
||||
// ── send ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('send <botId>')
|
||||
.description('Send a message to a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.requiredOption('--message <text>', 'Message content')
|
||||
.option('--reply-to <messageId>', 'Reply to a specific message')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { json?: boolean; message: string; replyTo?: string; target: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.sendMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
replyTo: options.replyTo,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── read ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('read <botId>')
|
||||
.description('Read messages from a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.option('--limit <n>', 'Max messages to fetch', String(DEFAULT_BOT_HISTORY_LIMIT))
|
||||
.option('--before <messageId>', 'Read messages before this ID')
|
||||
.option('--after <messageId>', 'Read messages after this ID')
|
||||
.option('--start-time <timestamp>', 'Start time as Unix seconds (Feishu/Lark)')
|
||||
.option('--end-time <timestamp>', 'End time as Unix seconds (Feishu/Lark)')
|
||||
.option('--cursor <token>', 'Pagination cursor from a previous response (Feishu/Lark)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
cursor?: string;
|
||||
endTime?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
startTime?: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.readMessages.query({
|
||||
after: options.after,
|
||||
before: options.before,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
cursor: options.cursor,
|
||||
endTime: options.endTime,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
startTime: options.startTime,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
m.timestamp || '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT', 'TIME']);
|
||||
|
||||
const r = result as any;
|
||||
if (r.hasMore && r.nextCursor) {
|
||||
console.log(
|
||||
`\nMore messages available. Use ${pc.dim(`--cursor ${r.nextCursor}`)} to fetch next page.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('edit <botId>')
|
||||
.description('Edit a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to edit')
|
||||
.requiredOption('--message <text>', 'New message content')
|
||||
.action(
|
||||
async (botId: string, options: { message: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.editMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} edited`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('delete <botId>')
|
||||
.description('Delete a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to delete')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (botId: string, options: { messageId: string; target: string; yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this message?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.deleteMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} deleted`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── search ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('search <botId>')
|
||||
.description('Search messages in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID to search in')
|
||||
.requiredOption('--query <text>', 'Search query')
|
||||
.option('--author-id <id>', 'Filter by author ID')
|
||||
.option('--limit <n>', 'Max results')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
authorId?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
query: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.searchMessages.query({
|
||||
authorId: options.authorId,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
query: options.query,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── react ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('react <botId>')
|
||||
.description('Add an emoji reaction to a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to react to')
|
||||
.requiredOption('--emoji <emoji>', 'Emoji to react with')
|
||||
.action(
|
||||
async (botId: string, options: { emoji: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.reactToMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
emoji: options.emoji,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Reacted with ${options.emoji} to message ${pc.bold(options.messageId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── reactions ───────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('reactions <botId>')
|
||||
.description('List reactions on a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getReactions.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = (result as any).reactions ?? [];
|
||||
if (reactions.length === 0) {
|
||||
console.log('No reactions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = reactions.map((r: any) => [r.emoji || '', String(r.count || 0)]);
|
||||
printTable(rows, ['EMOJI', 'COUNT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── pin ─────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pin <botId>')
|
||||
.description('Pin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to pin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.pinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Pinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── unpin ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('unpin <botId>')
|
||||
.description('Unpin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to unpin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.unpinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Unpinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── pins ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pins <botId>')
|
||||
.description('List pinned messages')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listPins.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No pinned messages.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
});
|
||||
|
||||
// ── poll ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('poll <botId>')
|
||||
.description('Create a poll')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--poll-question <text>', 'Poll question')
|
||||
.requiredOption('--poll-option <option>', 'Poll option (repeatable)', collectOptions, [])
|
||||
.option('--poll-multi', 'Allow multiple answers')
|
||||
.option('--poll-duration-hours <n>', 'Poll duration in hours')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
pollDurationHours?: string;
|
||||
pollMulti?: boolean;
|
||||
pollOption: string[];
|
||||
pollQuestion: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
if (options.pollOption.length < 2) {
|
||||
log.error('At least 2 poll options are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createPoll.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
duration: options.pollDurationHours
|
||||
? Number.parseInt(options.pollDurationHours, 10)
|
||||
: undefined,
|
||||
multipleAnswers: options.pollMulti,
|
||||
options: options.pollOption,
|
||||
question: options.pollQuestion,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Poll created${r.pollId ? ` (${pc.dim(r.pollId)})` : ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── thread (subcommand group) ───────────────────────────
|
||||
|
||||
const thread = message.command('thread').description('Manage threads');
|
||||
|
||||
thread
|
||||
.command('create <botId>')
|
||||
.description('Create a new thread')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--thread-name <name>', 'Thread name')
|
||||
.option('--message <text>', 'Initial message content')
|
||||
.option('--message-id <id>', 'Create thread from a message')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { message?: string; messageId?: string; target: string; threadName: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createThread.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
name: options.threadName,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Thread created${r.threadId ? ` (${pc.dim(r.threadId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
thread
|
||||
.command('list <botId>')
|
||||
.description('List threads in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listThreads.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const threads = (result as any).threads ?? [];
|
||||
if (threads.length === 0) {
|
||||
console.log('No threads found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = threads.map((t: any) => [
|
||||
t.id || '',
|
||||
t.name || '',
|
||||
String(t.messageCount ?? ''),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'MESSAGES']);
|
||||
});
|
||||
|
||||
thread
|
||||
.command('reply <botId>')
|
||||
.description('Reply to a thread')
|
||||
.requiredOption('--thread-id <id>', 'Thread ID')
|
||||
.requiredOption('--message <text>', 'Reply content')
|
||||
.action(async (botId: string, options: { message: string; threadId: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.replyToThread.mutate({
|
||||
botId,
|
||||
content: options.message,
|
||||
threadId: options.threadId,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
|
||||
});
|
||||
|
||||
// ── channel (subcommand group) ──────────────────────────
|
||||
|
||||
const channel = message.command('channel').description('Manage channels');
|
||||
|
||||
channel
|
||||
.command('list <botId>')
|
||||
.description('List channels')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--filter <type>', 'Filter by type')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { filter?: string; json?: boolean; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listChannels.query({
|
||||
botId,
|
||||
filter: options.filter,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = (result as any).channels ?? [];
|
||||
if (channels.length === 0) {
|
||||
console.log('No channels found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = channels.map((c: any) => [c.id || '', c.name || '', c.type || '']);
|
||||
printTable(rows, ['ID', 'NAME', 'TYPE']);
|
||||
},
|
||||
);
|
||||
|
||||
channel
|
||||
.command('info <botId>')
|
||||
.description('Get channel details')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getChannelInfo.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Channel: ${pc.bold(r.name || options.target)}`);
|
||||
if (r.type) console.log(` Type: ${r.type}`);
|
||||
if (r.memberCount != null) console.log(` Members: ${r.memberCount}`);
|
||||
if (r.description) console.log(` Description: ${r.description}`);
|
||||
});
|
||||
|
||||
// ── member ──────────────────────────────────────────────
|
||||
|
||||
const member = message.command('member').description('Member information');
|
||||
|
||||
member
|
||||
.command('info <botId>')
|
||||
.description('Get member details')
|
||||
.requiredOption('--member-id <id>', 'Member / user ID')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; memberId: string; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getMemberInfo.query({
|
||||
botId,
|
||||
memberId: options.memberId,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Member: ${pc.bold(r.displayName || r.username || options.memberId)}`);
|
||||
if (r.status) console.log(` Status: ${r.status}`);
|
||||
if (r.roles?.length) console.log(` Roles: ${r.roles.join(', ')}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function collectOptions(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
@@ -96,7 +96,7 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { spawnDaemon, stopDaemon } from '../daemon/manager';
|
||||
import { removeStatus, spawnDaemon, stopDaemon, writeStatus } from '../daemon/manager';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -130,6 +130,36 @@ describe('connect command', () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it('should persist deviceId in status for foreground connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist deviceId in status for daemon child connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect', '--daemon-child']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect to gateway', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
@@ -288,6 +318,7 @@ describe('connect command', () => {
|
||||
}
|
||||
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(removeStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired when refresh fails', async () => {
|
||||
|
||||
@@ -173,7 +173,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
||||
}
|
||||
|
||||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
let auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
@@ -221,16 +221,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
// Update status file for daemon mode
|
||||
// Update local connection status so other CLI commands can resolve the current device
|
||||
const updateStatus = (connectionStatus: string) => {
|
||||
if (isDaemonChild) {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
deviceId: client.currentDeviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -295,19 +294,30 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle auth expired
|
||||
// Handle auth expired — refresh token and reconnect automatically
|
||||
client.on('auth_expired', async () => {
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
// API keys don't expire; ignore stale auth_expired signals
|
||||
return;
|
||||
}
|
||||
|
||||
error('Authentication expired. Attempting to refresh...');
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed. Please reconnect.');
|
||||
} else {
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
info('Authentication expired. Attempting to refresh token...');
|
||||
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// refresh failed — fall through
|
||||
}
|
||||
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -322,8 +332,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info('Shutting down...');
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
if (isDaemonChild) {
|
||||
removeStatus();
|
||||
removePid();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('generate command', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': 'test-token',
|
||||
'X-lobe-chat-auth': 'test-xor-token',
|
||||
},
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
});
|
||||
|
||||
@@ -79,6 +79,57 @@ describe('message command', () => {
|
||||
);
|
||||
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep first page on the backend default offset for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'message',
|
||||
'list',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'-L',
|
||||
'200',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 200, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert page 2 to current 1 for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'message',
|
||||
'list',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--page',
|
||||
'2',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ current: 1, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support the short page flag for filtered queries', async () => {
|
||||
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1', '-P', '2']);
|
||||
|
||||
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ current: 1, topicId: 't1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function registerMessageCommand(program: Command) {
|
||||
.option('--topic-id <id>', 'Filter by topic ID')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('-P, --page <n>', 'Page number', '1')
|
||||
.option('--user', 'Only show user messages')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
@@ -32,7 +32,9 @@ export function registerMessageCommand(program: Command) {
|
||||
|
||||
const hasFilter = options.topicId || options.agentId;
|
||||
const pageSize = options.limit ? Number.parseInt(options.limit, 10) : undefined;
|
||||
const current = options.page ? Number.parseInt(options.page, 10) : undefined;
|
||||
const current = options.page
|
||||
? Math.max(Number.parseInt(options.page, 10) - 1, 0)
|
||||
: undefined;
|
||||
|
||||
let items: any[];
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
export function registerMigrateCommand(program: Command) {
|
||||
const migrate = program
|
||||
.command('migrate')
|
||||
.description('Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)');
|
||||
|
||||
registerOpenClawMigration(migrate);
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agent: {
|
||||
createAgent: { mutate: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
upsertDocument: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockConfirm } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../../settings', () => ({
|
||||
resolveServerUrl: () => 'https://app.lobehub.com',
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/format', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, confirm: mockConfirm };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { log } from '../../utils/logger';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const migrate = program.command('migrate');
|
||||
registerOpenClawMigration(migrate);
|
||||
return program;
|
||||
}
|
||||
|
||||
function writeFile(relativePath: string, content: string) {
|
||||
const fullPath = path.join(tmpDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// ── Setup / teardown ───────────────────────────────────
|
||||
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-test-'));
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('migrate openclaw', () => {
|
||||
// ── Profile parsing ────────────────────────────────
|
||||
|
||||
describe('agent profile from workspace', () => {
|
||||
it('should read name, description, and emoji from IDENTITY.md', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** 龙虾', '- **Creature:** AI 助手', '- **Emoji:** 🦞'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: '🦞',
|
||||
description: 'AI 助手',
|
||||
title: '龙虾',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out placeholder emoji like (待定)', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** TestBot', '- **Emoji:**', ' _(待定)_'].join('\n'),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'TestBot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to "OpenClaw" when no identity files exist', async () => {
|
||||
writeFile('doc.md', 'content');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'OpenClaw',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── File filtering ─────────────────────────────────
|
||||
|
||||
describe('file collection and filtering', () => {
|
||||
it('should exclude common directories like node_modules and .git', async () => {
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('node_modules/pkg/index.js', 'module');
|
||||
writeFile('.git/config', 'git');
|
||||
writeFile('.idea/workspace.xml', 'ide');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'README.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude files matching glob patterns like *.pyc and *.log', async () => {
|
||||
writeFile('main.py', 'print("hi")');
|
||||
writeFile('main.pyc', 'bytecode');
|
||||
writeFile('app.log', 'log data');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'main.py' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect workspace .gitignore', async () => {
|
||||
writeFile('.gitignore', 'secret.txt\ndata/\n');
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('secret.txt', 'password');
|
||||
writeFile('data/dump.sql', 'sql');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls.map(
|
||||
(c: any[]) => c[0].filename,
|
||||
);
|
||||
expect(filenames).toContain('README.md');
|
||||
expect(filenames).not.toContain('secret.txt');
|
||||
expect(filenames).not.toContain('data/dump.sql');
|
||||
});
|
||||
|
||||
it('should skip binary files during import', async () => {
|
||||
writeFile('readme.md', 'text content');
|
||||
// Write a file with null bytes (binary)
|
||||
const binPath = path.join(tmpDir, 'image.dat');
|
||||
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x01]));
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
// Only the text file should be upserted
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'readme.md' }),
|
||||
);
|
||||
// Binary file should show as skipped in output
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('skipped');
|
||||
});
|
||||
|
||||
it('should exclude database files by extension', async () => {
|
||||
writeFile('data.md', 'notes');
|
||||
writeFile('local.sqlite', 'fake-sqlite');
|
||||
writeFile('app.db', 'fake-db');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'data.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect files in subdirectories', async () => {
|
||||
writeFile('docs/guide.md', 'guide');
|
||||
writeFile('docs/api.md', 'api');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls
|
||||
.map((c: any[]) => c[0].filename)
|
||||
.sort();
|
||||
expect(filenames).toEqual(['docs/api.md', 'docs/guide.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dry run ────────────────────────────────────────
|
||||
|
||||
describe('--dry-run', () => {
|
||||
it('should list files without calling API', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(mockGetTrpcClient).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Dry run'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent resolution ───────────────────────────────
|
||||
|
||||
describe('agent resolution', () => {
|
||||
it('should use --agent-id directly when provided', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--agent-id',
|
||||
'agt_existing',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_existing' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve agent by --slug', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({ id: 'agt_inbox' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_inbox' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new agent by default', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_new' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Confirmation ───────────────────────────────────
|
||||
|
||||
describe('confirmation', () => {
|
||||
it('should cancel when user declines', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', tmpDir]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Cancelled.');
|
||||
});
|
||||
|
||||
it('should skip confirmation with --yes', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should exit when source path does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program
|
||||
.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', '/nonexistent/path'])
|
||||
.catch(() => {}); // process.exit throws
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should report failed files without aborting', async () => {
|
||||
writeFile('a.md', 'ok');
|
||||
writeFile('b.md', 'fail');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
// Files are iterated in readdir order; mock first success then failure
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('upload error'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(2);
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('1 imported');
|
||||
expect(allOutput).toContain('1 failed');
|
||||
});
|
||||
|
||||
it('should show no files message for empty workspace', async () => {
|
||||
// Only excluded items
|
||||
writeFile('.git/config', 'git');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith('No files found in workspace.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output ─────────────────────────────────────────
|
||||
|
||||
describe('output', () => {
|
||||
it('should print agent URL on completion', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_abc123' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('https://app.lobehub.com/agent/agt_abc123');
|
||||
});
|
||||
|
||||
it('should show friendly completion message on success', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('Migration complete');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ignore from 'ignore';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../../api/client';
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { resolveServerUrl } from '../../settings';
|
||||
import { confirm } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'OpenClaw';
|
||||
|
||||
// Files to look for agent identity (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
// Default ignore rules (gitignore syntax) applied when no .gitignore is found
|
||||
const DEFAULT_IGNORE_RULES = [
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
|
||||
// OpenClaw internal
|
||||
'.openclaw',
|
||||
|
||||
// OS artifacts
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'desktop.ini',
|
||||
|
||||
// IDE / editor
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.fleet',
|
||||
'.cursor',
|
||||
'.zed',
|
||||
'*.swp',
|
||||
'*.swo',
|
||||
'*~',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'.pnp',
|
||||
'.yarn',
|
||||
'bower_components',
|
||||
'vendor',
|
||||
'jspm_packages',
|
||||
|
||||
// Python
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'__pycache__',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.eggs',
|
||||
'*.egg-info',
|
||||
|
||||
// Ruby
|
||||
'.bundle',
|
||||
|
||||
// Rust
|
||||
'target',
|
||||
|
||||
// Go
|
||||
'go.sum',
|
||||
|
||||
// Java / JVM
|
||||
'.gradle',
|
||||
'.m2',
|
||||
|
||||
// .NET
|
||||
'bin',
|
||||
'obj',
|
||||
'packages',
|
||||
|
||||
// Build / cache / output
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.output',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.sass-cache',
|
||||
|
||||
// Env / secrets
|
||||
'.env',
|
||||
'.env.*',
|
||||
|
||||
// Test / coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
|
||||
// Infra
|
||||
'.terraform',
|
||||
|
||||
// Temp
|
||||
'tmp',
|
||||
'.tmp',
|
||||
|
||||
// Logs
|
||||
'*.log',
|
||||
'logs',
|
||||
|
||||
// Databases
|
||||
'*.sqlite',
|
||||
'*.sqlite3',
|
||||
'*.db',
|
||||
'*.db-shm',
|
||||
'*.db-wal',
|
||||
'*.ldb',
|
||||
'*.mdb',
|
||||
'*.accdb',
|
||||
|
||||
// Archives / binaries
|
||||
'*.zip',
|
||||
'*.tar',
|
||||
'*.tar.gz',
|
||||
'*.tgz',
|
||||
'*.gz',
|
||||
'*.bz2',
|
||||
'*.xz',
|
||||
'*.rar',
|
||||
'*.7z',
|
||||
'*.jar',
|
||||
'*.war',
|
||||
'*.dll',
|
||||
'*.so',
|
||||
'*.dylib',
|
||||
'*.exe',
|
||||
'*.bin',
|
||||
'*.o',
|
||||
'*.a',
|
||||
'*.lib',
|
||||
'*.class',
|
||||
|
||||
// Images / media / fonts
|
||||
'*.png',
|
||||
'*.jpg',
|
||||
'*.jpeg',
|
||||
'*.gif',
|
||||
'*.bmp',
|
||||
'*.ico',
|
||||
'*.webp',
|
||||
'*.svg',
|
||||
'*.mp3',
|
||||
'*.mp4',
|
||||
'*.wav',
|
||||
'*.avi',
|
||||
'*.mov',
|
||||
'*.mkv',
|
||||
'*.flac',
|
||||
'*.ogg',
|
||||
'*.pdf',
|
||||
'*.woff',
|
||||
'*.woff2',
|
||||
'*.ttf',
|
||||
'*.otf',
|
||||
'*.eot',
|
||||
|
||||
// Lock files
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'Gemfile.lock',
|
||||
'Cargo.lock',
|
||||
'poetry.lock',
|
||||
'composer.lock',
|
||||
];
|
||||
|
||||
interface AgentProfile {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract the agent name, description, and avatar emoji from
|
||||
* IDENTITY.md or SOUL.md. Falls back to "OpenClaw" if neither file
|
||||
* exists or parsing fails.
|
||||
*/
|
||||
function readAgentProfile(workspacePath: string): AgentProfile {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Try to extract **Name:** value
|
||||
const nameMatch = content.match(/\*{0,2}Name:?\*{0,2}\s*(.+)/i);
|
||||
const title = nameMatch ? nameMatch[1].trim() : DEFAULT_AGENT_NAME;
|
||||
|
||||
// Try to extract **Creature:** or **Vibe:** or **Description:** as description
|
||||
const descMatch = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
// Try to extract **Emoji:** value (single emoji)
|
||||
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
|
||||
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
|
||||
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
|
||||
const isPlaceholder =
|
||||
rawAvatar && /^[_*((].*[))_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
|
||||
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
return { title: DEFAULT_AGENT_NAME };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ignore filter for the workspace. Uses .gitignore if present,
|
||||
* otherwise falls back to a comprehensive default rule set.
|
||||
*/
|
||||
function buildIgnoreFilter(workspacePath: string) {
|
||||
const ig = ignore();
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Always apply default rules on top
|
||||
ig.add(DEFAULT_IGNORE_RULES);
|
||||
|
||||
return ig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all files under `dir`, filtered by ignore rules.
|
||||
* Returns paths relative to `baseDir`.
|
||||
*/
|
||||
function collectFiles(dir: string, baseDir: string, ig: ReturnType<typeof ignore>): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const relativePath = path.relative(baseDir, path.join(dir, entry.name));
|
||||
|
||||
// Directories need a trailing slash for ignore to match correctly
|
||||
const testPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
|
||||
if (ig.ignores(testPath)) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFiles(fullPath, baseDir, ig));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: read the first 8KB and look for null bytes.
|
||||
* If found, the file is likely binary and should be skipped.
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAgentLabel(profile: AgentProfile): string {
|
||||
return profile.avatar ? `${profile.avatar} ${profile.title}` : profile.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target agent ID.
|
||||
* Priority: --agent-id > --slug > create new agent from workspace profile.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: TrpcClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
profile: AgentProfile,
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
const label = formatAgentLabel(profile);
|
||||
log.info(`Creating new agent ${pc.bold(label)}...`);
|
||||
const result = await client.agent.createAgent.mutate({
|
||||
config: {
|
||||
avatar: profile.avatar,
|
||||
description: profile.description,
|
||||
title: profile.title,
|
||||
},
|
||||
});
|
||||
|
||||
const id = result.agentId;
|
||||
if (!id) {
|
||||
log.error('Failed to create agent — no agentId returned.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Agent created: ${pc.bold(label)}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function registerOpenClawMigration(migrate: Command) {
|
||||
migrate
|
||||
.command('openclaw')
|
||||
.description('Import OpenClaw workspace files as agent documents')
|
||||
.option(
|
||||
'--source <path>',
|
||||
'Path to OpenClaw workspace',
|
||||
path.join(os.homedir(), '.openclaw', 'workspace'),
|
||||
)
|
||||
.option('--agent-id <id>', 'Import into an existing agent by ID')
|
||||
.option('--slug <slug>', 'Import into an existing agent by slug (e.g. "inbox")')
|
||||
.option('--dry-run', 'Preview files without importing')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
slug?: string;
|
||||
source: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
// Check auth early so users don't scan files only to find out they're not logged in
|
||||
if (!options.dryRun) {
|
||||
await getTrpcClient();
|
||||
}
|
||||
|
||||
const workspacePath = path.resolve(options.source);
|
||||
|
||||
// Validate source directory
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
log.error(`OpenClaw workspace not found: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(workspacePath).isDirectory()) {
|
||||
log.error(`Not a directory: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read agent profile from workspace identity files
|
||||
const profile = readAgentProfile(workspacePath);
|
||||
const label = formatAgentLabel(profile);
|
||||
|
||||
// Collect files (respects .gitignore + default rules)
|
||||
const ig = buildIgnoreFilter(workspacePath);
|
||||
const files = collectFiles(workspacePath, workspacePath, ig);
|
||||
|
||||
if (files.length === 0) {
|
||||
log.info('No files found in workspace.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${pc.bold(String(files.length))} file(s) in ${pc.dim(workspacePath)}:\n`,
|
||||
);
|
||||
for (const f of files) {
|
||||
console.log(` ${pc.dim('•')} ${f}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info('Dry run — no changes made.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!options.yes) {
|
||||
const target = options.agentId
|
||||
? `agent ${pc.bold(options.agentId)}`
|
||||
: options.slug
|
||||
? `agent slug "${pc.bold(options.slug)}"`
|
||||
: `a new ${pc.bold(label)} agent`;
|
||||
const confirmed = await confirm(
|
||||
`Import ${files.length} file(s) as agent documents into ${target}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create or reuse agent
|
||||
const agentId = await resolveAgentId(client, options, profile);
|
||||
|
||||
console.log(`\nImporting to ${pc.bold(label)}...\n`);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
let skipped = 0;
|
||||
|
||||
for (const relativePath of files) {
|
||||
const fullPath = path.join(workspacePath, relativePath);
|
||||
|
||||
try {
|
||||
// Skip binary files that slipped through the extension filter
|
||||
if (isBinaryFile(fullPath)) {
|
||||
console.log(` ${pc.dim('○')} ${relativePath} ${pc.dim('(binary, skipped)')}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
await client.agentDocument.upsertDocument.mutate({
|
||||
agentId,
|
||||
content,
|
||||
createdAt: stat.birthtime,
|
||||
filename: relativePath,
|
||||
updatedAt: stat.mtime,
|
||||
});
|
||||
console.log(` ${pc.green('✓')} ${relativePath}`);
|
||||
success++;
|
||||
} catch (err: any) {
|
||||
console.log(` ${pc.red('✗')} ${relativePath} — ${err.message || err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const agentUrl = `${resolveServerUrl()}/agent/${agentId}`;
|
||||
const skippedInfo = skipped > 0 ? `, ${skipped} skipped` : '';
|
||||
console.log();
|
||||
if (failed === 0) {
|
||||
console.log(
|
||||
`${pc.green('✓')} Migration complete! ${pc.bold(String(success))} file(s) imported to ${pc.bold(label)}.${skippedInfo}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${pc.yellow('⚠')} Migration finished with issues: ${pc.bold(String(success))} imported, ${pc.red(String(failed))} failed${skippedInfo}.`,
|
||||
);
|
||||
}
|
||||
console.log(`\n ${pc.dim('→')} ${pc.underline(agentUrl)}`);
|
||||
console.log();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerNotifyCommand(program: Command) {
|
||||
program
|
||||
.command('notify')
|
||||
.description('Send a callback message to a topic and trigger the agent to process it')
|
||||
.requiredOption('--topic <topicId>', 'Target topic ID')
|
||||
.requiredOption('-c, --content <content>', 'Message content')
|
||||
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
|
||||
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
content: string;
|
||||
json?: boolean;
|
||||
threadId?: string;
|
||||
topic: string;
|
||||
}) => {
|
||||
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
try {
|
||||
const result = await client.agentNotify.notify.mutate({
|
||||
agentId: options.agentId,
|
||||
content: options.content,
|
||||
threadId: options.threadId,
|
||||
topicId: options.topic,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Message sent to topic ${pc.bold(result.topicId)}`);
|
||||
if (result.operationId) {
|
||||
console.log(` Operation ID: ${result.operationId}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${pc.red('✗')} Failed to send notification: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import type { KanbanColumn } from '../../utils/format';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printKanban,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
@@ -37,10 +39,12 @@ export function registerTaskCommand(program: Command) {
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--board', 'Display as kanban board grouped by status')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
board?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
@@ -59,8 +63,8 @@ export function registerTaskCommand(program: Command) {
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
// For tree/board mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree || options.board) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
@@ -77,6 +81,58 @@ export function registerTaskCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.board) {
|
||||
// Kanban board grouped by status
|
||||
const statusOrder = [
|
||||
'backlog',
|
||||
'blocked',
|
||||
'running',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'timeout',
|
||||
'canceled',
|
||||
];
|
||||
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
backlog: pc.dim,
|
||||
blocked: pc.red,
|
||||
canceled: pc.dim,
|
||||
completed: pc.green,
|
||||
failed: pc.red,
|
||||
paused: pc.yellow,
|
||||
running: pc.blue,
|
||||
timeout: pc.red,
|
||||
};
|
||||
|
||||
// Group tasks by status
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
const status = t.status || 'backlog';
|
||||
const list = grouped.get(status) || [];
|
||||
list.push(t);
|
||||
grouped.set(status, list);
|
||||
}
|
||||
|
||||
const kanbanColumns: KanbanColumn[] = statusOrder
|
||||
.filter((s) => grouped.has(s))
|
||||
.map((status) => ({
|
||||
color: statusColors[status],
|
||||
items: grouped.get(status)!.map((t: any) => ({
|
||||
badge: pc.dim(t.identifier),
|
||||
meta: t.assigneeAgentId ? `agent: ${t.assigneeAgentId}` : undefined,
|
||||
title: t.name || t.instruction,
|
||||
})),
|
||||
title: status.toUpperCase(),
|
||||
}));
|
||||
|
||||
console.log();
|
||||
printKanban(kanbanColumns);
|
||||
console.log();
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
@@ -240,23 +296,34 @@ export function registerTaskCommand(program: Command) {
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks ──
|
||||
// ── Subtasks (nested tree) ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
// Build lookup: which subtasks are completed (flatten tree)
|
||||
const collectCompleted = (nodes: typeof t.subtasks, set: Set<string>): Set<string> => {
|
||||
for (const s of nodes!) {
|
||||
if (s.status === 'completed') set.add(s.identifier);
|
||||
if (s.children) collectCompleted(s.children, set);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const completedIdentifiers = collectCompleted(t.subtasks, new Set());
|
||||
|
||||
const renderSubtasks = (nodes: typeof t.subtasks, indent: string) => {
|
||||
for (const s of nodes!) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
`${indent}${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
if (s.children && s.children.length > 0) {
|
||||
renderSubtasks(s.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
for (const s of t.subtasks) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
renderSubtasks(t.subtasks, ' ');
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
|
||||
@@ -77,6 +77,48 @@ describe('topic command', () => {
|
||||
expect.objectContaining({ agentId: 'a1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep first page on the backend default offset', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-L', '200']);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', pageSize: 200 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert page 2 to current 1', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'topic',
|
||||
'list',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--page',
|
||||
'2',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', current: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support the short page flag', async () => {
|
||||
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-P', '2']);
|
||||
|
||||
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', current: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function registerTopicCommand(program: Command) {
|
||||
.description('List topics')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '30')
|
||||
.option('--page <n>', 'Page number', '1')
|
||||
.option('-P, --page <n>', 'Page number', '1')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (options: {
|
||||
@@ -31,7 +31,8 @@ export function registerTopicCommand(program: Command) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
|
||||
if (options.page) input.current = Number.parseInt(options.page, 10);
|
||||
const page = options.page ? Number.parseInt(options.page, 10) : undefined;
|
||||
if (page !== undefined && page > 1) input.current = page - 1;
|
||||
|
||||
const result = await client.topic.getTopics.query(input as any);
|
||||
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const OFFICIAL_AGENT_GATEWAY_URL = 'https://agent-gateway.lobehub.com';
|
||||
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
|
||||
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
@@ -23,6 +23,7 @@ function getLogFilePath() {
|
||||
|
||||
export interface DaemonStatus {
|
||||
connectionStatus: string;
|
||||
deviceId?: string;
|
||||
gatewayUrl: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
@@ -159,7 +160,7 @@ export function spawnDaemon(args: string[]): number {
|
||||
// Re-run the same entry with --daemon-child (internal flag)
|
||||
const child = spawn(process.execPath, [...process.execArgv, ...args, '--daemon-child'], {
|
||||
detached: true,
|
||||
env: { ...process.env, LOBEHUB_DAEMON: '1' },
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', LOBEHUB_DAEMON: '1' },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createProgram } from './program';
|
||||
|
||||
createProgram().parse();
|
||||
createProgram().parse(process.argv, { from: 'node' });
|
||||
|
||||
@@ -20,13 +20,16 @@ import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerManCommand } from './commands/man';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
import { registerMessageCommand } from './commands/message';
|
||||
import { registerMigrateCommand } from './commands/migrate';
|
||||
import { registerModelCommand } from './commands/model';
|
||||
import { registerNotifyCommand } from './commands/notify';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
@@ -61,15 +64,18 @@ export function createProgram() {
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerNotifyCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { OFFICIAL_AGENT_GATEWAY_URL, OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface StoredSettings {
|
||||
agentGatewayUrl?: string;
|
||||
gatewayUrl?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
@@ -25,15 +26,24 @@ export function resolveServerUrl(): string {
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function resolveAgentGatewayUrl(): string | undefined {
|
||||
const envUrl = normalizeUrl(process.env.AGENT_GATEWAY_URL);
|
||||
const settingsUrl = normalizeUrl(loadSettings()?.agentGatewayUrl);
|
||||
|
||||
return envUrl || settingsUrl || OFFICIAL_AGENT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const agentGatewayUrl = normalizeUrl(settings.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) {
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {}
|
||||
@@ -50,14 +60,16 @@ export function loadSettings(): StoredSettings | null {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data) as StoredSettings;
|
||||
const agentGatewayUrl = normalizeUrl(parsed.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(parsed.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) return null;
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamAgentEvents } from './agentStream';
|
||||
import { streamAgentEvents, streamAgentEventsViaWebSocket } from './agentStream';
|
||||
|
||||
vi.mock('./logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
heartbeat: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -193,3 +194,391 @@ describe('streamAgentEvents', () => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket stream tests ──────────────────────────────
|
||||
|
||||
let capturedWs: MockWebSocket | undefined;
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: ((ev: any) => void) | null = null;
|
||||
onmessage: ((ev: any) => void) | null = null;
|
||||
onerror: ((ev: any) => void) | null = null;
|
||||
onclose: ((ev: any) => void) | null = null;
|
||||
|
||||
sent: string[] = [];
|
||||
private autoAuthSuccess = true;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
autoAuth = true,
|
||||
) {
|
||||
this.autoAuthSuccess = autoAuth;
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
// Trigger onopen on next microtask (after handlers are assigned)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.({ type: 'open' });
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'auth' && this.autoAuthSuccess) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'auth_success' }) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
// Async like real WebSocket — fires after current microtask
|
||||
queueMicrotask(() => this.onclose?.({ code: 1000, reason: '' }));
|
||||
}
|
||||
|
||||
simulateMessage(msg: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||
}
|
||||
}
|
||||
|
||||
describe('streamAgentEventsViaWebSocket', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWs = undefined;
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
});
|
||||
|
||||
/** Wait for microtasks + short delay so WS open/auth cycle completes */
|
||||
const flush = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
it('should connect, authenticate, and send resume', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
|
||||
{ token: 'test-token', type: 'auth' },
|
||||
{ lastEventId: '', type: 'resume' },
|
||||
]);
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render agent_event messages using existing renderEvent', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'step_start' },
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Hello WS!' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Hello WS!');
|
||||
});
|
||||
|
||||
it('should output JSON when json option is set', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
json: true,
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_init"'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_end"'));
|
||||
});
|
||||
|
||||
it('should reject on auth failure', async () => {
|
||||
// Override mock to return auth_failed instead of auth_success
|
||||
(globalThis as any).WebSocket = class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url, false); // disable auto auth_success
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
}
|
||||
|
||||
override send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'auth') {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({ reason: 'invalid token', type: 'auth_failed' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await expect(
|
||||
streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'bad-token',
|
||||
}),
|
||||
).rejects.toThrow('Gateway auth failed');
|
||||
});
|
||||
|
||||
it('should resolve on session_complete', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.simulateMessage({ id: '1', summary: 'All done', type: 'session_complete' });
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore heartbeat_ack messages', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({ type: 'heartbeat_ack' });
|
||||
expect(stdoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should construct correct WebSocket URL from HTTPS gateway URL', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://agent-gateway.lobehub.com',
|
||||
operationId: 'op-123',
|
||||
token: 'tok',
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(capturedWs!.url).toBe('wss://agent-gateway.lobehub.com/ws?operationId=op-123');
|
||||
|
||||
capturedWs!.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render a multi-step agent run with tool calls', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'tok',
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
const { log } = await import('./logger');
|
||||
|
||||
// Step 1: thinking + text + tool call
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 2, type: 'step_start' },
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'reasoning', reasoning: 'Let me search...' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Searching for news.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 4,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '4',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { toolCalling: { apiName: 'search', id: 'tc-1' } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 5,
|
||||
type: 'tool_start',
|
||||
},
|
||||
id: '5',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 6, type: 'stream_end' },
|
||||
id: '6',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepIndex: 0 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 7,
|
||||
type: 'step_complete',
|
||||
},
|
||||
id: '7',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
// Step 2: tool result + final text
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 1, timestamp: 8, type: 'step_start' },
|
||||
id: '8',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: {
|
||||
isSuccess: true,
|
||||
payload: { toolCalling: { id: 'tc-1' } },
|
||||
result: { content: 'Results...' },
|
||||
},
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 9,
|
||||
type: 'tool_end',
|
||||
},
|
||||
id: '9',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Here are the results.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 10,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '10',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { cost: { total: 0.05 }, stepCount: 2, usage: { total_tokens: 500 } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 11,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '11',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Verify reasoning was rendered (dim)
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('Let me search...'));
|
||||
// Verify text chunks
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Searching for news.');
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Here are the results.');
|
||||
// Verify tool call was logged
|
||||
expect(log.toolCall).toHaveBeenCalledWith('search', 'tc-1', undefined);
|
||||
// Verify tool result was logged
|
||||
expect(log.toolResult).toHaveBeenCalled();
|
||||
// Verify finish line
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Agent finished'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pc from 'picocolors';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { log } from './logger';
|
||||
|
||||
@@ -16,6 +17,12 @@ interface StreamOptions {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
interface WebSocketStreamOptions extends StreamOptions {
|
||||
gatewayUrl: string;
|
||||
operationId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the agent SSE stream and render events to the terminal.
|
||||
* Resolves when the stream ends (agent_runtime_end or connection close).
|
||||
@@ -152,6 +159,126 @@ export function replayAgentEvents(events: AgentStreamEvent[], options: StreamOpt
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
/**
|
||||
* Connect to the Agent Gateway via WebSocket and render events to the terminal.
|
||||
* Resolves when the session completes or the connection closes.
|
||||
*/
|
||||
export async function streamAgentEventsViaWebSocket(
|
||||
options: WebSocketStreamOptions,
|
||||
): Promise<void> {
|
||||
const { gatewayUrl, operationId, token, ...streamOpts } = options;
|
||||
const wsUrl = urlJoin(
|
||||
gatewayUrl.replace(/^http/, 'ws'),
|
||||
`/ws?operationId=${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
|
||||
log.debug(`Connecting to gateway: ${wsUrl}`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const jsonEvents: AgentStreamEvent[] = [];
|
||||
const ctx = createRenderContext();
|
||||
let lastEventId = '';
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let jsonPrinted = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token, type: 'auth' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
log.debug('Gateway authenticated');
|
||||
// Request all buffered events (covers events pushed before WS connected)
|
||||
ws.send(JSON.stringify({ lastEventId: '', type: 'resume' }));
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_failed') {
|
||||
cleanup();
|
||||
reject(new Error(`Gateway auth failed: ${msg.reason}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'heartbeat_ack') return;
|
||||
|
||||
if (msg.type === 'agent_event') {
|
||||
const agentEvent: AgentStreamEvent = msg.event;
|
||||
if (msg.id) lastEventId = msg.id;
|
||||
|
||||
if (streamOpts.json) {
|
||||
jsonEvents.push(agentEvent);
|
||||
} else {
|
||||
renderEvent(agentEvent, ctx, streamOpts);
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'agent_runtime_end') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
} else if (!streamOpts.json) {
|
||||
renderEnd(agentEvent);
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'error') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
log.error(
|
||||
`Agent error: ${agentEvent.data?.message || agentEvent.data?.error || 'Unknown error'}`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'session_complete') {
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────
|
||||
|
||||
interface RenderContext {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { readStatus } from '../daemon/manager';
|
||||
|
||||
export function resolveLocalDeviceId(): string | undefined {
|
||||
return readStatus()?.deviceId;
|
||||
}
|
||||
@@ -387,6 +387,102 @@ export function printCalendarHeatmap(
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Kanban Board ─────────────────────────────────────
|
||||
|
||||
export interface KanbanColumn {
|
||||
color?: (s: string) => string;
|
||||
items: KanbanCard[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface KanbanCard {
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a kanban board with side-by-side columns.
|
||||
* Adapts column width to terminal width automatically.
|
||||
*/
|
||||
export function printKanban(columns: KanbanColumn[]) {
|
||||
// Filter out empty columns
|
||||
const cols = columns.filter((c) => c.items.length > 0);
|
||||
if (cols.length === 0) return;
|
||||
|
||||
const termWidth = process.stdout.columns || 100;
|
||||
// Each column gets equal width, with 1-char gap between
|
||||
const colWidth = Math.max(20, Math.floor((termWidth - (cols.length - 1)) / cols.length));
|
||||
const innerWidth = colWidth - 4; // 2 chars border + 2 padding
|
||||
|
||||
const maxRows = Math.max(...cols.map((c) => c.items.length));
|
||||
|
||||
// ── Header ──
|
||||
const topBorder = cols
|
||||
.map((c) => {
|
||||
const titleStr = ` ${c.title} (${c.items.length}) `;
|
||||
const color = c.color || pc.white;
|
||||
const remaining = colWidth - 2 - displayWidth(titleStr);
|
||||
const left = Math.floor(remaining / 2);
|
||||
const right = remaining - left;
|
||||
return color(
|
||||
'┌' + '─'.repeat(Math.max(0, left)) + titleStr + '─'.repeat(Math.max(0, right)) + '┐',
|
||||
);
|
||||
})
|
||||
.join(' ');
|
||||
console.log(topBorder);
|
||||
|
||||
// ── Rows ──
|
||||
for (let row = 0; row < maxRows; row++) {
|
||||
const line = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
|
||||
const badge = item.badge ? item.badge + ' ' : '';
|
||||
const badgeWidth = displayWidth(badge);
|
||||
const titleMaxWidth = innerWidth - badgeWidth;
|
||||
const title = truncate(item.title, titleMaxWidth);
|
||||
const titleWidth = displayWidth(title);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - badgeWidth - titleWidth - 2));
|
||||
return color('│') + ' ' + badge + title + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(line);
|
||||
|
||||
// Print meta line if any card in this row has meta
|
||||
const hasMeta = cols.some((c) => c.items[row]?.meta);
|
||||
if (hasMeta) {
|
||||
const metaLine = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item?.meta) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
const meta = truncate(item.meta, innerWidth);
|
||||
const metaWidth = displayWidth(meta);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - metaWidth - 2));
|
||||
return color('│') + ' ' + pc.dim(meta) + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(metaLine);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom border ──
|
||||
const bottomBorder = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
return color('└' + '─'.repeat(colWidth - 2) + '┘');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -9,6 +9,10 @@ export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
fixedExtension: false,
|
||||
format: ['esm'],
|
||||
minify: true,
|
||||
outputOptions: {
|
||||
codeSplitting: false,
|
||||
},
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
@@ -109,6 +109,26 @@ const config = {
|
||||
|
||||
console.info('📦 Downloading agent-browser binary...');
|
||||
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
|
||||
|
||||
// Build and copy CLI bundle for embedding
|
||||
console.info('📦 Building CLI for embedding...');
|
||||
execSync('npm run build', { stdio: 'inherit', cwd: path.resolve(__dirname, '../cli') });
|
||||
const cliSrc = path.resolve(__dirname, '../cli/dist/index.js');
|
||||
const cliDest = path.resolve(__dirname, 'resources/bin/lobe-cli.js');
|
||||
await fs.copyFile(cliSrc, cliDest);
|
||||
|
||||
// Write a minimal package.json next to the CLI bundle so that
|
||||
// createRequire('../package.json') resolves correctly in the packaged app.
|
||||
// The CLI script lives at Resources/bin/lobe-cli.js, so '../package.json'
|
||||
// resolves to Resources/package.json.
|
||||
const cliPkg = JSON.parse(
|
||||
await fs.readFile(path.resolve(__dirname, '../cli/package.json'), 'utf8'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.resolve(__dirname, 'resources/cli-package.json'),
|
||||
JSON.stringify({ name: cliPkg.name, type: 'module', version: cliPkg.version }),
|
||||
);
|
||||
console.info('✅ CLI bundle copied to resources/bin/lobe-cli.js');
|
||||
},
|
||||
/**
|
||||
* AfterPack hook for post-processing:
|
||||
@@ -296,7 +316,10 @@ const config = {
|
||||
releaseNotes: process.env.RELEASE_NOTES || undefined,
|
||||
},
|
||||
|
||||
extraResources: [{ from: 'resources/bin', to: 'bin' }],
|
||||
extraResources: [
|
||||
{ from: 'resources/bin', to: 'bin' },
|
||||
{ from: 'resources/cli-package.json', to: 'package.json' },
|
||||
],
|
||||
|
||||
win: {
|
||||
executableName: 'LobeHub',
|
||||
|
||||
@@ -90,7 +90,6 @@ export default defineConfig({
|
||||
outDir: 'dist/preload',
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/main'),
|
||||
|
||||
@@ -68,7 +68,9 @@
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
var locale = navigator.language || 'en-US';
|
||||
// Check URL query parameter for locale (set by Electron main process from stored settings)
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var locale = urlParams.get('lng') || navigator.language || 'en-US';
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir =
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build:cli": "cd ../cli && bun run build",
|
||||
"build:main": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build",
|
||||
"build:run-unpack": "electron .",
|
||||
"dev": "electron-vite dev",
|
||||
@@ -68,7 +69,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.0.2",
|
||||
"electron": "41.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const VERSION = '0.20.1';
|
||||
const VERSION = '0.24.0';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const binDir = path.join(__dirname, '..', 'resources', 'bin');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tagWhite, writeJSON } from './utils';
|
||||
export const genDefaultLocale = () => {
|
||||
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
|
||||
|
||||
// 确保入口语言目录存在
|
||||
// Ensure entry locale directory exists
|
||||
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
|
||||
if (!existsSync(entryLocaleDir)) {
|
||||
mkdirSync(entryLocaleDir, { recursive: true });
|
||||
@@ -23,7 +23,7 @@ export const genDefaultLocale = () => {
|
||||
for (const [ns, value] of data) {
|
||||
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
|
||||
|
||||
// 确保目录存在
|
||||
// Ensure directory exists
|
||||
const dir = dirname(filepath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { genDefaultLocale } from './genDefaultLocale';
|
||||
import { genDiff } from './genDiff';
|
||||
import { split } from './utils';
|
||||
|
||||
// 确保所有语言目录存在
|
||||
// Ensure all locale directories exist
|
||||
const ensureLocalesDirs = () => {
|
||||
[i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => {
|
||||
const dir = localeDir(locale);
|
||||
@@ -15,20 +15,20 @@ const ensureLocalesDirs = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 运行工作流
|
||||
// Run workflow
|
||||
const run = async () => {
|
||||
// 确保目录存在
|
||||
// Ensure directories exist
|
||||
ensureLocalesDirs();
|
||||
|
||||
// 差异分析
|
||||
// Diff analysis
|
||||
split('差异分析');
|
||||
genDiff();
|
||||
|
||||
// 生成默认语言文件
|
||||
// Generate default locale files
|
||||
split('生成默认语言文件');
|
||||
genDefaultLocale();
|
||||
|
||||
// 生成国际化文件
|
||||
// Generate i18n files
|
||||
split('生成国际化文件');
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user