openclacky 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +99 -356
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/CHANGELOG.md +42 -0
- data/docs/system-skill-authoring-guide.md +1 -1
- data/lib/clacky/agent/tool_executor.rb +3 -1
- data/lib/clacky/agent.rb +12 -7
- data/lib/clacky/agent_config.rb +9 -3
- data/lib/clacky/brand_config.rb +19 -4
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
- data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
- data/lib/clacky/message_history.rb +26 -1
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
- data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +26 -0
- data/lib/clacky/server/channel.rb +3 -0
- data/lib/clacky/server/http_server.rb +75 -4
- data/lib/clacky/server/server_master.rb +35 -13
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +7 -1
- data/lib/clacky/telemetry.rb +1 -16
- data/lib/clacky/tools/browser.rb +8 -5
- data/lib/clacky/tools/glob.rb +11 -38
- data/lib/clacky/tools/grep.rb +7 -16
- data/lib/clacky/ui2/markdown_renderer.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_ignore_helper.rb +49 -0
- data/lib/clacky/utils/gitignore_parser.rb +27 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +248 -31
- data/lib/clacky/web/app.js +51 -1
- data/lib/clacky/web/channels.js +98 -28
- data/lib/clacky/web/datepicker.js +205 -0
- data/lib/clacky/web/i18n.js +48 -9
- data/lib/clacky/web/index.html +33 -6
- data/lib/clacky/web/onboard.js +46 -4
- data/lib/clacky/web/sessions.js +33 -72
- data/lib/clacky/web/settings.js +42 -4
- data/lib/clacky/web/version.js +52 -1
- metadata +21 -10
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: channel-
|
|
2
|
+
name: channel-manager
|
|
3
3
|
description: |
|
|
4
|
-
Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
|
|
4
|
+
Configure IM platform channels (Feishu, WeCom, Weixin, Discord, Telegram, DingTalk) for openclacky.
|
|
5
5
|
Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
|
|
6
|
-
Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "
|
|
7
|
-
"channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
|
|
8
|
-
"send message to weixin", "send message to feishu", "send message to wecom".
|
|
6
|
+
Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "setup discord", "setup telegram", "setup dingtalk",
|
|
7
|
+
"channel config", "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
|
|
8
|
+
"send message to weixin", "send message to feishu", "send message to wecom", "send message to discord", "send message to telegram", "send message to dingtalk".
|
|
9
9
|
Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
|
|
10
10
|
argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
|
|
11
11
|
allowed-tools:
|
|
@@ -28,13 +28,13 @@ Configure IM platform channels for openclacky.
|
|
|
28
28
|
|
|
29
29
|
| User says | Subcommand |
|
|
30
30
|
|---|---|
|
|
31
|
-
| `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat` | setup |
|
|
31
|
+
| `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat`, `setup discord`, `setup telegram`, `setup dingtalk` | setup |
|
|
32
32
|
| `channel status` | status |
|
|
33
|
-
| `channel enable feishu/wecom/weixin` | enable |
|
|
34
|
-
| `channel disable feishu/wecom/weixin` | disable |
|
|
33
|
+
| `channel enable feishu/wecom/weixin/discord/telegram/dingtalk` | enable |
|
|
34
|
+
| `channel disable feishu/wecom/weixin/discord/telegram/dingtalk` | disable |
|
|
35
35
|
| `channel reconfigure` | reconfigure |
|
|
36
36
|
| `channel doctor` | doctor |
|
|
37
|
-
| `send <message> to weixin/feishu/wecom` | send |
|
|
37
|
+
| `send <message> to weixin/feishu/wecom/discord/telegram/dingtalk` | send |
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
@@ -51,7 +51,9 @@ Response shape (example):
|
|
|
51
51
|
{"channels":[
|
|
52
52
|
{"platform":"feishu","enabled":true,"running":true,"has_config":true,"app_id":"cli_xxx","domain":"https://open.feishu.cn","allowed_users":[]},
|
|
53
53
|
{"platform":"wecom","enabled":false,"running":false,"has_config":false,"bot_id":""},
|
|
54
|
-
{"platform":"weixin","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://ilinkai.weixin.qq.com","allowed_users":[]}
|
|
54
|
+
{"platform":"weixin","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://ilinkai.weixin.qq.com","allowed_users":[]},
|
|
55
|
+
{"platform":"discord","enabled":true,"running":true,"has_config":true,"has_token":true,"allowed_users":[]}
|
|
56
|
+
{"platform":"telegram","enabled":true,"running":true,"has_config":true,"has_token":true,"base_url":"https://api.telegram.org","parse_mode":"Markdown","allowed_users":[]}
|
|
55
57
|
]}
|
|
56
58
|
```
|
|
57
59
|
|
|
@@ -64,14 +66,20 @@ Platform Enabled Running Details
|
|
|
64
66
|
feishu ✅ yes ✅ yes app_id: cli_xxx...
|
|
65
67
|
wecom ❌ no ❌ no (not configured)
|
|
66
68
|
weixin ✅ yes ✅ yes has_token: true
|
|
69
|
+
discord ✅ yes ✅ yes has_token: true
|
|
70
|
+
telegram ✅ yes ✅ yes has_token: true
|
|
71
|
+
dingtalk ✅ yes ✅ yes client_id: ding_xxx...
|
|
67
72
|
─────────────────────────────────────────────────────
|
|
68
73
|
```
|
|
69
74
|
|
|
70
75
|
- Feishu: show `app_id` (truncated to 12 chars)
|
|
71
76
|
- WeCom: show `bot_id` if present
|
|
72
77
|
- Weixin: show `has_token: true/false` (token value is never displayed)
|
|
78
|
+
- Discord: show `has_token: true/false` (token value is never displayed)
|
|
79
|
+
- Telegram: show `has_token: true/false` (bot token is never displayed)
|
|
80
|
+
- DingTalk: show `client_id` (truncated to 12 chars)
|
|
73
81
|
|
|
74
|
-
If the API is unreachable or returns an empty list: "No channels configured yet. Run `/channel-
|
|
82
|
+
If the API is unreachable or returns an empty list: "No channels configured yet. Run `/channel-manager setup` to get started."
|
|
75
83
|
|
|
76
84
|
---
|
|
77
85
|
|
|
@@ -83,6 +91,9 @@ Ask:
|
|
|
83
91
|
> 1. Feishu
|
|
84
92
|
> 2. WeCom (Enterprise WeChat)
|
|
85
93
|
> 3. Weixin (Personal WeChat via iLink QR login)
|
|
94
|
+
> 4. Discord
|
|
95
|
+
> 5. Telegram (Bot API)
|
|
96
|
+
> 6. DingTalk
|
|
86
97
|
|
|
87
98
|
---
|
|
88
99
|
|
|
@@ -216,7 +227,7 @@ Call `request_user_feedback`:
|
|
|
216
227
|
zh:
|
|
217
228
|
```json
|
|
218
229
|
{
|
|
219
|
-
"question": "是否要安装「飞书 CLI」?装好之后 AI
|
|
230
|
+
\"question\": \"是否要安装「飞书 CLI」?装好之后 AI 可以帮你操作飞书云文档等能力。不装也 OK。\",
|
|
220
231
|
"options": ["启用", "跳过"]
|
|
221
232
|
}
|
|
222
233
|
```
|
|
@@ -224,7 +235,7 @@ zh:
|
|
|
224
235
|
en:
|
|
225
236
|
```json
|
|
226
237
|
{
|
|
227
|
-
"question": "Install Feishu CLI? With it, the AI can
|
|
238
|
+
"question": "Install Feishu CLI? With it, the AI can help you work with Feishu Docs and more. Skipping is fine.",
|
|
228
239
|
"options": ["Enable", "Skip"]
|
|
229
240
|
}
|
|
230
241
|
```
|
|
@@ -236,8 +247,7 @@ If the user picks Enable, run:
|
|
|
236
247
|
```bash
|
|
237
248
|
lark-cli --version > /dev/null 2>&1 || npm install -g @larksuite/cli
|
|
238
249
|
echo -n "<APP_SECRET>" | lark-cli config init --app-id <APP_ID> --app-secret-stdin --brand feishu
|
|
239
|
-
|
|
240
|
-
ruby "SKILL_DIR/import_lark_skills.rb"
|
|
250
|
+
ruby "SKILL_DIR/install_feishu_skills.rb"
|
|
241
251
|
lark-cli auth login --recommend
|
|
242
252
|
```
|
|
243
253
|
|
|
@@ -344,6 +354,110 @@ Tell the user while waiting:
|
|
|
344
354
|
|
|
345
355
|
---
|
|
346
356
|
|
|
357
|
+
### Discord setup
|
|
358
|
+
|
|
359
|
+
Discord requires manual portal interaction (hCaptcha gates Application creation). The browser just navigates the user to the portal; the user clicks through and pastes the bot token + app id back.
|
|
360
|
+
|
|
361
|
+
#### Step 1 — Open the developer portal
|
|
362
|
+
|
|
363
|
+
Get the portal URL from the script and open it in the browser:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
PORTAL_URL=$(ruby "SKILL_DIR/discord_setup.rb" --portal-url)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Open it: `browser(action="navigate", url="<PORTAL_URL>")`. If the browser tool is not configured, invoke `browser-setup` first, then retry.
|
|
370
|
+
|
|
371
|
+
#### Step 2 — Guide the user through the portal (one round-trip)
|
|
372
|
+
|
|
373
|
+
Tell the user **all** of the following in a single message, then call `request_user_feedback` to collect the values in one reply:
|
|
374
|
+
|
|
375
|
+
> In the Discord Developer Portal I just opened:
|
|
376
|
+
>
|
|
377
|
+
> 1. Click **New Application** (top-right). Name it whatever you like (e.g. "Open Clacky"), check the ToS box, click **Create**.
|
|
378
|
+
> 2. In the left nav click **Bot**.
|
|
379
|
+
> 3. Scroll down to **Privileged Gateway Intents** and turn on **MESSAGE CONTENT INTENT**, then click **Save Changes**.
|
|
380
|
+
> 4. Scroll up, click **Reset Token** → **Yes, do it!**. Click **Copy** to copy the bot token. (This is the only time the token is shown — don't navigate away before copying.)
|
|
381
|
+
> 5. In the left nav click **General Information**. Copy the **Application ID**.
|
|
382
|
+
>
|
|
383
|
+
> Paste both values back here in this format (one line):
|
|
384
|
+
>
|
|
385
|
+
> `token=YOUR_BOT_TOKEN app_id=YOUR_APPLICATION_ID`
|
|
386
|
+
|
|
387
|
+
If the user is chatting in a non-English language, append the localized label in parens after each bolded English button name (e.g. `**Bot**(机器人)`). The English label stays primary — it's what they physically click in the portal.
|
|
388
|
+
|
|
389
|
+
Use `request_user_feedback` to collect the reply. Parse with tolerant regex (`token=\S+`, `app_id=\d+`).
|
|
390
|
+
|
|
391
|
+
If the reply is malformed (missing either field), apologise briefly and ask again with the exact same format reminder. Up to 3 retries; after that, surface the original message and stop.
|
|
392
|
+
|
|
393
|
+
#### Step 3 — Validate, save, invite, wait
|
|
394
|
+
|
|
395
|
+
1. Validate the token and save credentials:
|
|
396
|
+
```bash
|
|
397
|
+
ruby "SKILL_DIR/discord_setup.rb" --validate "<BOT_TOKEN>"
|
|
398
|
+
```
|
|
399
|
+
On success the script prints `{"bot_id":"...","username":"..."}` and the adapter starts.
|
|
400
|
+
|
|
401
|
+
2. Generate the invite URL using the application id from Step 2:
|
|
402
|
+
```bash
|
|
403
|
+
ruby "SKILL_DIR/discord_setup.rb" --invite-url "<APP_ID>"
|
|
404
|
+
```
|
|
405
|
+
Open it: `browser(action="navigate", url="<INVITE_URL>")`. Tell the user:
|
|
406
|
+
> Pick your server from the dropdown → **Continue** → **Authorize**. I'll detect when the bot joins.
|
|
407
|
+
>
|
|
408
|
+
> If the dropdown is empty, you don't have a server yet — open <https://discord.com/channels/@me>, click **Add a Server** (the **+** button on the left sidebar) → **Create My Own** → **For me and my friends** → name it → **Create**, then re-open the invite link.
|
|
409
|
+
|
|
410
|
+
3. Wait for the bot to join a guild (long-poll, 10 min timeout). Run with `timeout: 620`:
|
|
411
|
+
```bash
|
|
412
|
+
ruby "SKILL_DIR/discord_setup.rb" --watch-guild
|
|
413
|
+
```
|
|
414
|
+
On exit 0: "✅ Discord channel configured! Bot is in `<guild_name>`. Mention it or DM it from any channel."
|
|
415
|
+
On timeout: offer to re-open the invite URL — the bot token stays valid.
|
|
416
|
+
|
|
417
|
+
### Telegram setup (Bot API)
|
|
418
|
+
|
|
419
|
+
Telegram setup is by far the simplest — no browser automation, no QR. The user creates a bot via @BotFather and pastes the token here.
|
|
420
|
+
|
|
421
|
+
#### Step 1 — Create a bot via @BotFather
|
|
422
|
+
|
|
423
|
+
Tell the user:
|
|
424
|
+
|
|
425
|
+
> Open Telegram and start a chat with **@BotFather** (https://t.me/BotFather). Send `/newbot`, choose a display name and a username ending in `bot`. BotFather will reply with an HTTP API token that looks like `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`. Paste the token here.
|
|
426
|
+
>
|
|
427
|
+
> Optional: if your network blocks `api.telegram.org`, also tell me the base URL of your self-hosted Bot API server (e.g. `https://my-tg-proxy.example.com`). Otherwise leave it blank.
|
|
428
|
+
|
|
429
|
+
Wait for the user's reply. Parse the token (matches `^\d+:[\w-]{30,}$`).
|
|
430
|
+
|
|
431
|
+
#### Step 2 — Save credentials and validate
|
|
432
|
+
|
|
433
|
+
Call the server API. It calls `getMe` against the Bot API to validate the token before persisting:
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/telegram \
|
|
437
|
+
-H "Content-Type: application/json" \
|
|
438
|
+
-d '{"bot_token":"<TOKEN>","base_url":"<BASE_URL_OR_OMIT>"}'
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
- `200 { "ok": true }` — token validated and saved. The adapter starts long-polling immediately.
|
|
442
|
+
- `422 { "ok": false, "error": "..." }` — show the error (commonly "Unauthorized" → wrong token) and offer to retry.
|
|
443
|
+
|
|
444
|
+
On success:
|
|
445
|
+
|
|
446
|
+
> ✅ Telegram channel configured. Open your bot in Telegram and send any message to start chatting.
|
|
447
|
+
>
|
|
448
|
+
> **For group chats**: You must disable Privacy Mode in @BotFather first (`/mybots → Bot Settings → Group Privacy → Turn off`), then remove and re-add the bot to the group. Otherwise the bot cannot receive any messages — including @-mentions.
|
|
449
|
+
|
|
450
|
+
#### Notes
|
|
451
|
+
|
|
452
|
+
- **Group chats — Privacy Mode (IMPORTANT)**: By default Telegram enables Privacy Mode for all bots, which means the bot **cannot receive any group messages, including @-mentions**. To use the bot in a group you MUST disable Privacy Mode first:
|
|
453
|
+
1. Open @BotFather → `/mybots` → select your bot → `Bot Settings` → `Group Privacy` → **Turn off**
|
|
454
|
+
2. **Remove the bot from the group and re-add it** — the permission change does not apply to groups the bot is already in.
|
|
455
|
+
After that, the bot will respond whenever it is @-mentioned or directly replied to.
|
|
456
|
+
- **Self-hosted Bot API**: set `base_url` when `api.telegram.org` is unreachable. See https://github.com/tdlib/telegram-bot-api for the official self-hosted server.
|
|
457
|
+
- **`allowed_users`**: restrict which Telegram user IDs the bot will respond to. Find a user's numeric ID by messaging @userinfobot.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
347
461
|
## `enable`
|
|
348
462
|
|
|
349
463
|
Call the server API to re-enable the platform (this reads from disk, sets enabled, saves, and hot-reloads):
|
|
@@ -372,6 +486,32 @@ Say: "❌ `<platform>` channel disabled."
|
|
|
372
486
|
|
|
373
487
|
---
|
|
374
488
|
|
|
489
|
+
### DingTalk setup
|
|
490
|
+
|
|
491
|
+
#### Step 1 — Get QR code
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
ruby "SKILL_DIR/dingtalk_setup.rb" --print-qr
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Parse the last line starting with `{` to get `qr_url` and `device_code`. On non-0 exit, show the error and abort.
|
|
498
|
+
|
|
499
|
+
#### Step 2 — Show QR and wait
|
|
500
|
+
|
|
501
|
+
Show `qr_url` to the user, ask them to scan with the DingTalk mobile app and tap "Create New Robot", then call `request_user_feedback`.
|
|
502
|
+
|
|
503
|
+
#### Step 3 — Poll for authorization
|
|
504
|
+
|
|
505
|
+
```bash
|
|
506
|
+
ruby "SKILL_DIR/dingtalk_setup.rb" --poll "<device_code>"
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
- **0** → "✅ DingTalk channel configured! Find your robot in DingTalk and send it a message." Stop.
|
|
510
|
+
- **2** → not scanned yet. Ask user to confirm, then re-poll. If output contains `WAITING_TIMEOUT` or `expired`, restart from Step 1.
|
|
511
|
+
- **1** → show the error and abort.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
375
515
|
## `reconfigure`
|
|
376
516
|
|
|
377
517
|
1. Show current config via `GET /api/channels` (mask secrets — show last 4 chars only).
|
|
@@ -391,15 +531,37 @@ Check each item, report ✅ / ❌ with remediation:
|
|
|
391
531
|
- Feishu: `app_id`, `app_secret` present and non-empty
|
|
392
532
|
- WeCom: `bot_id`, `secret` present and non-empty
|
|
393
533
|
- Weixin: `token` present and non-empty in `channels.yml`
|
|
534
|
+
- Discord: `bot_token` present and non-empty in `channels.yml`
|
|
535
|
+
- Telegram: `bot_token` present and non-empty
|
|
394
536
|
3. **Feishu credentials** (if enabled) — run the token API call, check `code=0`.
|
|
395
537
|
4. **Weixin token** (if enabled) — call `GET /api/channels` and check `has_token: true` for the weixin entry.
|
|
396
|
-
5. **
|
|
538
|
+
5. **Telegram credentials** (if enabled) — call `getMe` against the Bot API:
|
|
539
|
+
```bash
|
|
540
|
+
BOT_TOKEN=$(ruby -ryaml -e 'puts (YAML.load_file(File.expand_path("~/.clacky/channels.yml"))["channels"]["telegram"]["bot_token"] rescue "")')
|
|
541
|
+
BASE_URL=$(ruby -ryaml -e 'puts (YAML.load_file(File.expand_path("~/.clacky/channels.yml"))["channels"]["telegram"]["base_url"] || "https://api.telegram.org" rescue "https://api.telegram.org")')
|
|
542
|
+
curl -s "$BASE_URL/bot$BOT_TOKEN/getMe" | grep -q '"ok":true' && echo "✅ Telegram OK" || echo "❌ Telegram credentials rejected by getMe"
|
|
543
|
+
```
|
|
544
|
+
6. **WeCom credentials** (if enabled) — search today's log:
|
|
397
545
|
```bash
|
|
398
546
|
grep -iE "wecom adapter loop started|WeCom authentication failed|WeCom WS error response|WecomAdapter" \
|
|
399
547
|
~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
|
|
400
548
|
```
|
|
401
549
|
- `WeCom authentication failed` or non-zero errcode → ❌ "WeCom credentials incorrect"
|
|
402
550
|
- `adapter loop started` with no auth error → ✅
|
|
551
|
+
6. **Discord credentials** (if enabled) — call `GET /api/channels` and check `has_token: true`. Search today's log:
|
|
552
|
+
```bash
|
|
553
|
+
grep -iE "DiscordAdapter|discord-gateway|/users/@me failed" \
|
|
554
|
+
~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
|
|
555
|
+
```
|
|
556
|
+
- `/users/@me failed` → ❌ "Discord token invalid or revoked — re-run setup"
|
|
557
|
+
- `authenticated as` with no error → ✅
|
|
558
|
+
7. **DingTalk credentials** (if enabled) — search today's log:
|
|
559
|
+
```bash
|
|
560
|
+
grep -iE "dingtalk-ws|DingTalk.*error|stream.*error" \
|
|
561
|
+
~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
|
|
562
|
+
```
|
|
563
|
+
- `WebSocket connected` → ✅
|
|
564
|
+
- `Stream endpoint error` or `token error` → ❌ "DingTalk credentials invalid — re-run setup"
|
|
403
565
|
|
|
404
566
|
---
|
|
405
567
|
|
|
@@ -410,7 +572,7 @@ Proactively send a message to a user via an IM channel adapter.
|
|
|
410
572
|
### Parse the request
|
|
411
573
|
|
|
412
574
|
Extract two things from the user's instruction:
|
|
413
|
-
- **platform** — one of `weixin`, `feishu`, `wecom`
|
|
575
|
+
- **platform** — one of `weixin`, `feishu`, `wecom`, `discord`, `telegram`, `dingtalk`
|
|
414
576
|
- **message** — the text content to send
|
|
415
577
|
|
|
416
578
|
If the platform cannot be inferred, ask the user to clarify.
|
|
@@ -450,7 +612,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/
|
|
|
450
612
|
### Constraints & notes
|
|
451
613
|
|
|
452
614
|
- **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
|
|
453
|
-
- **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
|
|
615
|
+
- **Feishu / WeCom / Discord / Telegram**: No per-message token required. As long as the adapter is running and the `user_id` / `chat_id` (or Discord channel/user id) is valid, the message will be delivered. For Telegram specifically, the `user_id` must be a Telegram chat_id that the bot can write to — the user must have sent at least one message to the bot first.
|
|
454
616
|
- This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
|
|
455
617
|
|
|
456
618
|
---
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# dingtalk_setup.rb — DingTalk channel setup via Device Flow (QR scan).
|
|
5
|
+
#
|
|
6
|
+
# Modes:
|
|
7
|
+
# --print-qr Phase 1+2: call init/begin, print QR URL as JSON, exit immediately.
|
|
8
|
+
# --poll <device_code> Phase 3+4+5: poll until SUCCESS, save credentials, wait for WS.
|
|
9
|
+
#
|
|
10
|
+
# Environment:
|
|
11
|
+
# CLACKY_SERVER_PORT, CLACKY_SERVER_HOST — clacky server coordinates
|
|
12
|
+
|
|
13
|
+
require "json"
|
|
14
|
+
require "net/http"
|
|
15
|
+
require "net/https"
|
|
16
|
+
require "uri"
|
|
17
|
+
|
|
18
|
+
DINGTALK_REG_BASE = "https://oapi.dingtalk.com"
|
|
19
|
+
# Registration source ID assigned by DingTalk (not a brand string — do not rebrand).
|
|
20
|
+
DINGTALK_REG_SOURCE = "DING_DWS_CLAW"
|
|
21
|
+
POLL_INTERVAL = 3
|
|
22
|
+
POLL_TIMEOUT = 300
|
|
23
|
+
|
|
24
|
+
CLACKY_SERVER_URL = begin
|
|
25
|
+
url = "http://#{ENV.fetch("CLACKY_SERVER_HOST")}:#{ENV.fetch("CLACKY_SERVER_PORT")}"
|
|
26
|
+
uri = URI.parse(url)
|
|
27
|
+
raise "Invalid CLACKY_SERVER_URL: #{url}" unless uri.is_a?(URI::HTTP) && uri.host && uri.port
|
|
28
|
+
url
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def step(msg); puts("[dingtalk-setup] #{msg}"); end
|
|
32
|
+
def ok(msg); puts("[dingtalk-setup] ✅ #{msg}"); end
|
|
33
|
+
def warn(msg); puts("[dingtalk-setup] ⚠️ #{msg}"); end
|
|
34
|
+
def fail!(msg)
|
|
35
|
+
puts("[dingtalk-setup] ❌ #{msg}")
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post_json(url, payload)
|
|
40
|
+
uri = URI.parse(url)
|
|
41
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
42
|
+
http.use_ssl = uri.scheme == "https"
|
|
43
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
44
|
+
req.body = JSON.generate(payload)
|
|
45
|
+
resp = http.request(req)
|
|
46
|
+
data = JSON.parse(resp.body)
|
|
47
|
+
fail! "API error (#{resp.code}): #{data["errmsg"] || resp.body}" if data["errcode"] && data["errcode"] != 0
|
|
48
|
+
data
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
fail! "JSON parse error from #{url}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def server_post(path, body)
|
|
54
|
+
uri = URI(CLACKY_SERVER_URL)
|
|
55
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 3, read_timeout: 10) do |h|
|
|
56
|
+
req = Net::HTTP::Post.new(path, "Content-Type" => "application/json")
|
|
57
|
+
req.body = JSON.generate(body)
|
|
58
|
+
h.request(req)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def server_get(path)
|
|
63
|
+
uri = URI(CLACKY_SERVER_URL)
|
|
64
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 3, read_timeout: 10) do |h|
|
|
65
|
+
h.request(Net::HTTP::Get.new(path))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ── Mode: --print-qr ─────────────────────────────────────────────────────────
|
|
70
|
+
# Call init + begin, print JSON with qr_url / device_code / expires_in, exit 0.
|
|
71
|
+
def mode_print_qr
|
|
72
|
+
step "Phase 1 — Starting DingTalk Device Flow registration..."
|
|
73
|
+
|
|
74
|
+
init_data = post_json("#{DINGTALK_REG_BASE}/app/registration/init",
|
|
75
|
+
{ source: DINGTALK_REG_SOURCE })
|
|
76
|
+
nonce = init_data["nonce"].to_s.strip
|
|
77
|
+
fail! "Missing nonce in init response" if nonce.empty?
|
|
78
|
+
|
|
79
|
+
begin_data = post_json("#{DINGTALK_REG_BASE}/app/registration/begin", { nonce: nonce })
|
|
80
|
+
device_code = begin_data["device_code"].to_s.strip
|
|
81
|
+
qr_url = begin_data["verification_uri_complete"].to_s.strip
|
|
82
|
+
expires_in = (begin_data["expires_in"] || POLL_TIMEOUT).to_i
|
|
83
|
+
|
|
84
|
+
fail! "Missing device_code in begin response" if device_code.empty?
|
|
85
|
+
fail! "Missing verification_uri_complete" if qr_url.empty?
|
|
86
|
+
|
|
87
|
+
ok "Device Flow started. QR expires in #{expires_in}s."
|
|
88
|
+
puts JSON.generate({ qr_url: qr_url, device_code: device_code, expires_in: expires_in })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Mode: --poll <device_code> ────────────────────────────────────────────────
|
|
92
|
+
# Poll until SUCCESS or a terminal state. Exits with:
|
|
93
|
+
# 0 — SUCCESS: credentials saved and adapter started
|
|
94
|
+
# 2 — WAITING: user hasn't scanned yet (Agent should ask user to scan and retry)
|
|
95
|
+
# 1 — terminal failure (expired, fail, or server error)
|
|
96
|
+
def mode_poll(device_code, expires_in: POLL_TIMEOUT, interval: POLL_INTERVAL)
|
|
97
|
+
step "Phase 3 — Checking DingTalk authorization..."
|
|
98
|
+
|
|
99
|
+
client_id = nil
|
|
100
|
+
client_secret = nil
|
|
101
|
+
deadline = Time.now + expires_in
|
|
102
|
+
|
|
103
|
+
loop do
|
|
104
|
+
if Time.now > deadline
|
|
105
|
+
puts "[dingtalk-setup] WAITING_TIMEOUT"
|
|
106
|
+
exit 2
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
poll_data = post_json("#{DINGTALK_REG_BASE}/app/registration/poll",
|
|
110
|
+
{ device_code: device_code })
|
|
111
|
+
status = poll_data["status"].to_s.upcase
|
|
112
|
+
|
|
113
|
+
case status
|
|
114
|
+
when "WAITING"
|
|
115
|
+
puts "[dingtalk-setup] WAITING"
|
|
116
|
+
exit 2
|
|
117
|
+
when "SUCCESS"
|
|
118
|
+
client_id = poll_data["client_id"].to_s.strip
|
|
119
|
+
client_secret = poll_data["client_secret"].to_s.strip
|
|
120
|
+
fail! "Authorization succeeded but missing client credentials" if client_id.empty? || client_secret.empty?
|
|
121
|
+
ok "Authorization complete! client_id=#{client_id}"
|
|
122
|
+
break
|
|
123
|
+
when "EXPIRED"
|
|
124
|
+
fail! "Authorization QR code expired. Please re-run."
|
|
125
|
+
when "FAIL"
|
|
126
|
+
fail! "Authorization failed: #{poll_data["fail_reason"] || "unknown reason"}"
|
|
127
|
+
else
|
|
128
|
+
warn "Unknown status=#{status}, retrying..."
|
|
129
|
+
sleep interval
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ── Phase 4: Save credentials to clacky server ─────────────────────────────
|
|
134
|
+
step "Phase 4 — Saving credentials to clacky server..."
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
res = server_post("/api/channels/dingtalk",
|
|
138
|
+
{ client_id: client_id, client_secret: client_secret, enabled: true })
|
|
139
|
+
if res.code.to_i == 200
|
|
140
|
+
ok "Credentials saved, DingTalk Stream adapter starting..."
|
|
141
|
+
else
|
|
142
|
+
body = JSON.parse(res.body) rescue { "error" => res.body }
|
|
143
|
+
fail! "Server rejected credentials: #{body["error"] || res.body}"
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
fail! "Could not reach clacky server: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── Phase 5: Wait for Stream Mode WebSocket to connect ─────────────────────
|
|
150
|
+
step "Phase 5 — Waiting for DingTalk Stream connection..."
|
|
151
|
+
|
|
152
|
+
ws_ready = false
|
|
153
|
+
ws_deadline = Time.now + 30
|
|
154
|
+
|
|
155
|
+
loop do
|
|
156
|
+
break if Time.now > ws_deadline
|
|
157
|
+
begin
|
|
158
|
+
res = server_get("/api/channels")
|
|
159
|
+
channels = JSON.parse(res.body)["channels"] || []
|
|
160
|
+
dingtalk = channels.find { |c| c["platform"] == "dingtalk" }
|
|
161
|
+
if dingtalk&.fetch("running", false)
|
|
162
|
+
ws_ready = true
|
|
163
|
+
break
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
warn "Channel status check failed: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
sleep 2
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if ws_ready
|
|
172
|
+
ok "DingTalk Stream WebSocket connected."
|
|
173
|
+
else
|
|
174
|
+
warn "Stream connection not confirmed within 30s — it may still be starting."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
ok "🎉 DingTalk channel setup complete! Search for your robot in DingTalk to start chatting."
|
|
178
|
+
ok " client_id: #{client_id}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
182
|
+
case ARGV[0]
|
|
183
|
+
when "--print-qr"
|
|
184
|
+
mode_print_qr
|
|
185
|
+
when "--poll"
|
|
186
|
+
device_code = ARGV[1].to_s.strip
|
|
187
|
+
fail! "Usage: dingtalk_setup.rb --poll <device_code>" if device_code.empty?
|
|
188
|
+
mode_poll(device_code)
|
|
189
|
+
else
|
|
190
|
+
fail! "Usage: dingtalk_setup.rb --print-qr | --poll <device_code>"
|
|
191
|
+
end
|