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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +99 -356
  3. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  4. data/CHANGELOG.md +42 -0
  5. data/docs/system-skill-authoring-guide.md +1 -1
  6. data/lib/clacky/agent/tool_executor.rb +3 -1
  7. data/lib/clacky/agent.rb +12 -7
  8. data/lib/clacky/agent_config.rb +9 -3
  9. data/lib/clacky/brand_config.rb +19 -4
  10. data/lib/clacky/cli.rb +1 -1
  11. data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
  12. data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  13. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
  14. data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  15. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  16. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
  17. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
  18. data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
  19. data/lib/clacky/message_history.rb +26 -1
  20. data/lib/clacky/providers.rb +29 -4
  21. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
  22. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
  23. data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
  24. data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
  25. data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
  26. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
  27. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
  28. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
  29. data/lib/clacky/server/channel/channel_config.rb +26 -0
  30. data/lib/clacky/server/channel.rb +3 -0
  31. data/lib/clacky/server/http_server.rb +75 -4
  32. data/lib/clacky/server/server_master.rb +35 -13
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +7 -1
  35. data/lib/clacky/telemetry.rb +1 -16
  36. data/lib/clacky/tools/browser.rb +8 -5
  37. data/lib/clacky/tools/glob.rb +11 -38
  38. data/lib/clacky/tools/grep.rb +7 -16
  39. data/lib/clacky/ui2/markdown_renderer.rb +1 -1
  40. data/lib/clacky/ui2/ui_controller.rb +2 -1
  41. data/lib/clacky/utils/file_ignore_helper.rb +49 -0
  42. data/lib/clacky/utils/gitignore_parser.rb +27 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +248 -31
  45. data/lib/clacky/web/app.js +51 -1
  46. data/lib/clacky/web/channels.js +98 -28
  47. data/lib/clacky/web/datepicker.js +205 -0
  48. data/lib/clacky/web/i18n.js +48 -9
  49. data/lib/clacky/web/index.html +33 -6
  50. data/lib/clacky/web/onboard.js +46 -4
  51. data/lib/clacky/web/sessions.js +33 -72
  52. data/lib/clacky/web/settings.js +42 -4
  53. data/lib/clacky/web/version.js +52 -1
  54. metadata +21 -10
  55. data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
  56. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
  57. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
  58. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
  59. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
@@ -1,11 +1,11 @@
1
1
  ---
2
- name: channel-setup
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", "channel config",
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-setup setup` to get started."
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 可以帮你操作飞书云文档、电子表格、多维表格、知识库、日历、任务等几乎全部飞书能力,不只是聊天,而是能\"做事\"。不装也 OK",
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 operate Feishu Docs, Sheets, Bitable, Wiki, Calendar, Tasks and almost every Feishu capability — not just chat, but actually get things done. Skipping is fine.",
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
- npx -y skills add larksuite/cli -y -g
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. **WeCom credentials** (if enabled) — search today's log:
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