feat(arcrun): Phase 2 降級假零件成 recipe + credential 鏈路修復
Phase 1(credential 注入鏈路):
- 修 auth_static_key ENCRYPTION_KEY 漂移根因(見 docs/incidents)
- component-loader: readBodyOnce() 修 "Body has already been used"
Phase 2(降級假零件成 recipe,registry/components 33→22):
- 引擎: RecipeDefinition 加 auth_service(多 recipe 共用一把 auth)
auth-dispatcher 先查 recipe.auth_service 再 fallback componentId
- 引擎: auth_static_key inject.path + makeRecipeRunner {{auth.K}}
(endpoint 可插 secret,解 telegram 類 URL-path token)
- 引擎: makeRecipeRunner auto-body 剔除 _ 前綴內部欄位
- 降級並刪除: kbdb_{get,create_block,patch_block,delete,ingest}
gmail/telegram/line_notify/google_sheets(改建為 recipe)
- 刪除: ai_transform_{compile,run}(Arcrun 是 AI 呼叫的工具,
工作流不該內嵌 AI 節點回頭呼叫 AI)
- deferred(源碼暫留): claude_api/km_writer(交 Mira 收成工作流)、
kbdb_upsert_block(交 KBDB 出 upsert endpoint)
文件: DECISIONS.md(工作流是 default/建零件人類閘門/AI→工具)、
BACKLOG.md、auth-recipe.md §七、docs/incidents 加密 key 漂移
驗收: KBDB get/create/ingest/delete 2xx;telegram auth 注入綠;
gmail/sheets/line recipe 正確但缺 credential 未驗收;
kbdb patch 403 為 KBDB 端 bug(已交 kbdb/docs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
canonical_id: "ai_transform_compile"
|
||||
display_name: "AI 轉換編譯"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [description]
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
description: 自然語言描述,如「把日期改成台灣格式」
|
||||
example_input: {}
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
transform_id:
|
||||
type: string
|
||||
fn_preview:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常編譯"
|
||||
given: '{"description":"把日期改成台灣格式","example_input":{}}'
|
||||
then_contains: '"transform_id"'
|
||||
- scenario: "缺少 description"
|
||||
given: '{}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [ai, transform, compile, nlp, codegen]
|
||||
description: "Phase 0 stub:接收自然語言描述,回傳 transform_id 和 fn_preview placeholder。Phase 2 接 AI host function。"
|
||||
config_example: |
|
||||
my_ai_compile: # 節點名稱(可自訂)
|
||||
description: "把日期改成台灣格式" # 自然語言轉換描述(必填)
|
||||
example_input: {"date": "2024-01-15"} # 範例輸入,用於輔助 AI 生成(選填)
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,53 +0,0 @@
|
||||
// ai_transform_compile — 接收自然語言描述,輸出 transform stub
|
||||
// Phase 0: 輸出 placeholder,Phase 2 再接 AI host function
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Description string `json:"description"`
|
||||
ExampleInput json.RawMessage `json:"example_input"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Description == "" {
|
||||
writeError("description 必填")
|
||||
return
|
||||
}
|
||||
|
||||
transformID := "at-" + strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"transform_id": transformID,
|
||||
"fn_preview": "// AI generated\n// TODO: Phase 2 will implement AI-powered code generation\nreturn input;",
|
||||
"description": input.Description,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
canonical_id: "ai_transform_run"
|
||||
display_name: "AI 轉換執行"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [transform_id, input]
|
||||
properties:
|
||||
transform_id:
|
||||
type: string
|
||||
description: 由 ai_transform_compile 回傳的 ID
|
||||
input: {}
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
transform_id:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常執行"
|
||||
given: '{"transform_id":"at-123","input":{"date":"2024-01-15"}}'
|
||||
then_contains: '"transform_id":"at-123"'
|
||||
- scenario: "缺少 transform_id"
|
||||
given: '{"input":{}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [ai, transform, run, execute]
|
||||
description: "Phase 0 stub:使用 transform_id 執行轉換,目前直接回傳 input。Phase 2 接 AI host function。"
|
||||
config_example: |
|
||||
my_ai_run: # 節點名稱(可自訂)
|
||||
transform_id: "at-abc123" # 由 ai_transform_compile 回傳的轉換 ID(必填)
|
||||
input: {"date": "2024-01-15"} # 要套用轉換的輸入資料(必填)
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,48 +0,0 @@
|
||||
// ai_transform_run — 使用已編譯的 transform_id 執行轉換
|
||||
// Phase 0: stub 實作,直接回傳 input
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
TransformID string `json:"transform_id"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.TransformID == "" {
|
||||
writeError("transform_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": input.Input,
|
||||
"transform_id": input.TransformID,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -59,6 +59,9 @@ type AuthInjectSpec struct {
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body map[string]string `json:"body,omitempty"`
|
||||
// Path:要注入 endpoint URL path 的 secret(如 telegram /bot{token}/)。
|
||||
// key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。
|
||||
Path map[string]string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
@@ -159,6 +162,7 @@ func main() {
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
authPath := interpolateRecord(recipe.Inject.Path, secrets, runtime)
|
||||
|
||||
// 3.5 Basic Auth 自動編碼:若 header 值為 "Basic <x>:<y>" (冒號代表未編碼的 user:pass),
|
||||
// 將冒號分隔部分做 base64。這涵蓋 twilio / jira / mailgun 等 Basic Auth recipe。
|
||||
@@ -185,6 +189,7 @@ func main() {
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"auth_path": authPath,
|
||||
"runtime": runtime,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
canonical_id: "gmail"
|
||||
display_name: "Gmail 發信"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [to, subject, body, access_token]
|
||||
properties:
|
||||
to:
|
||||
type: string
|
||||
description: 收件人 Email
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
access_token:
|
||||
type: string
|
||||
description: Google OAuth access token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 access_token"
|
||||
given: '{"to":"test@example.com","subject":"test","body":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 to"
|
||||
given: '{"subject":"test","body":"hello","access_token":"token"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, google, gmail, email, oauth]
|
||||
description: "透過 Gmail API 發送 Email。透過 host function 呼叫,需要 Google OAuth access_token。"
|
||||
credentials_required:
|
||||
- key: gmail_token
|
||||
type: google_oauth
|
||||
description: "Google OAuth access token(gmail.send scope)"
|
||||
inject_as: access_token
|
||||
config_example: |
|
||||
send_email: # 節點名稱(可自訂)
|
||||
to: "" # 收件人 Email(必填)
|
||||
subject: "" # 主旨(必填)
|
||||
body: "" # 內文(必填)
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,139 +0,0 @@
|
||||
// gmail — 透過 Gmail API 發送 Email
|
||||
// 透過 host function 呼叫 Gmail API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.To == "" {
|
||||
writeError("to 必填")
|
||||
return
|
||||
}
|
||||
if input.Subject == "" {
|
||||
writeError("subject 必填")
|
||||
return
|
||||
}
|
||||
if input.AccessToken == "" {
|
||||
writeError("access_token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
// 建立 RFC 2822 格式的 email,base64url 編碼
|
||||
emailLines := []string{
|
||||
"To: " + input.To,
|
||||
"Subject: " + input.Subject,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
input.Body,
|
||||
}
|
||||
emailRaw := strings.Join(emailLines, "\r\n")
|
||||
encoded := base64URLEncode([]byte(emailRaw))
|
||||
|
||||
bodyData, _ := json.Marshal(map[string]string{"raw": encoded})
|
||||
|
||||
apiURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
method := "POST"
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.AccessToken,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := bodyData
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
uintptr(unsafe.Pointer(&bodyBytes[0])), uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Gmail API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
var responseData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &responseData); err != nil {
|
||||
responseData = map[string]interface{}{"raw": responseStr}
|
||||
}
|
||||
|
||||
messageID, _ := responseData["id"].(string)
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"message_id": messageID},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// base64URLEncode — 不依賴 encoding/base64(TinyGo 相容)
|
||||
func base64URLEncode(data []byte) string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(data); i += 3 {
|
||||
b0 := data[i]
|
||||
var b1, b2 byte
|
||||
if i+1 < len(data) {
|
||||
b1 = data[i+1]
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
b2 = data[i+2]
|
||||
}
|
||||
sb.WriteByte(chars[b0>>2])
|
||||
sb.WriteByte(chars[((b0&0x3)<<4)|(b1>>4)])
|
||||
if i+1 < len(data) {
|
||||
sb.WriteByte(chars[((b1&0xf)<<2)|(b2>>6)])
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
sb.WriteByte(chars[b2&0x3f])
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
canonical_id: "google_sheets"
|
||||
display_name: "Google 試算表"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [spreadsheet_id, range, access_token]
|
||||
properties:
|
||||
spreadsheet_id:
|
||||
type: string
|
||||
range:
|
||||
type: string
|
||||
description: 如 Sheet1!A1:B10
|
||||
action:
|
||||
type: string
|
||||
enum: [read, write]
|
||||
default: read
|
||||
values:
|
||||
type: array
|
||||
description: write 時的資料(二維陣列)
|
||||
access_token:
|
||||
type: string
|
||||
description: Google OAuth access token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
values: {}
|
||||
range:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 access_token"
|
||||
given: '{"spreadsheet_id":"abc","range":"Sheet1!A1"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 spreadsheet_id"
|
||||
given: '{"range":"Sheet1!A1","access_token":"token"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, google, sheets, oauth]
|
||||
description: "讀取或寫入 Google 試算表。透過 host function 呼叫 Google Sheets API,需要 OAuth access_token。"
|
||||
credentials_required:
|
||||
- key: google_oauth
|
||||
type: google_oauth
|
||||
description: "Google OAuth access token(spreadsheets scope)"
|
||||
inject_as: access_token
|
||||
config_example: |
|
||||
read_sheet: # 節點名稱(可自訂)
|
||||
spreadsheet_id: "" # 試算表 ID(必填)
|
||||
range: "" # 範圍,如 Sheet1!A1:B10(必填)
|
||||
# access_token 由 credentials.yaml 的 google_oauth 自動注入
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,135 +0,0 @@
|
||||
// google_sheets — 讀取或寫入 Google 試算表
|
||||
// 透過 host function 呼叫 Google Sheets API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
SpreadsheetID string `json:"spreadsheet_id"`
|
||||
Range string `json:"range"`
|
||||
Action string `json:"action"`
|
||||
Values [][]json.RawMessage `json:"values"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.SpreadsheetID == "" {
|
||||
writeError("spreadsheet_id 必填")
|
||||
return
|
||||
}
|
||||
if input.Range == "" {
|
||||
writeError("range 必填")
|
||||
return
|
||||
}
|
||||
if input.AccessToken == "" {
|
||||
writeError("access_token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
action := input.Action
|
||||
if action == "" {
|
||||
action = "read"
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.AccessToken,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
var apiURL, method, bodyStr string
|
||||
|
||||
switch action {
|
||||
case "read":
|
||||
apiURL = "https://sheets.googleapis.com/v4/spreadsheets/" + input.SpreadsheetID + "/values/" + input.Range
|
||||
method = "GET"
|
||||
bodyStr = ""
|
||||
case "write":
|
||||
apiURL = "https://sheets.googleapis.com/v4/spreadsheets/" + input.SpreadsheetID + "/values/" + input.Range + "?valueInputOption=RAW"
|
||||
method = "PUT"
|
||||
bodyData, _ := json.Marshal(map[string]interface{}{
|
||||
"range": input.Range,
|
||||
"majorDimension": "ROWS",
|
||||
"values": input.Values,
|
||||
})
|
||||
bodyStr = string(bodyData)
|
||||
default:
|
||||
writeError("不支援的 action: " + action)
|
||||
return
|
||||
}
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Google Sheets API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
var responseData interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &responseData); err != nil {
|
||||
responseData = responseStr
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"values": responseData,
|
||||
"range": input.Range,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
canonical_id: "kbdb_create_block"
|
||||
display_name: "KBDB 建立 Block"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, content]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx)
|
||||
content:
|
||||
type: string
|
||||
description: block 內容
|
||||
type:
|
||||
type: string
|
||||
description: block type(note / chat / page 等,預設 block)
|
||||
parent_id:
|
||||
type: string
|
||||
description: 父 block id(留言鏈用)
|
||||
user_id:
|
||||
type: string
|
||||
description: 擁有者 user_id / namespace
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記
|
||||
page_name:
|
||||
type: string
|
||||
description: 所屬 page
|
||||
tags_json:
|
||||
type: string
|
||||
description: tags JSON 字串
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳(含新 block 的 id)
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 content"
|
||||
given: '{"api_key":"pk_live_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "建立留言(type=chat + parent_id)"
|
||||
given: '{"api_key":"pk_live_x","content":"hi","type":"chat","parent_id":"abc"}'
|
||||
then_contains: 'success'
|
||||
tags: [data, storage, kbdb, create, primitive]
|
||||
description: "建立單一 KBDB block(POST /blocks),不切多 chunks。支援 parent_id 給留言鏈用。Mira 留言/AI 回覆使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
reply:
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
content: "我的留言"
|
||||
type: "chat"
|
||||
parent_id: "{{previous_node.output.block_id}}"
|
||||
user_id: "inkstone_leo"
|
||||
page_name: "my-post"
|
||||
@@ -1,3 +0,0 @@
|
||||
module kbdb_create_block
|
||||
|
||||
go 1.21
|
||||
@@ -1,152 +0,0 @@
|
||||
// kbdb_create_block — POST 一個單一 block 到 KBDB(支援 parent_id,給留言鏈用)
|
||||
// 對應 KBDB endpoint: POST /blocks(不是 ingest)
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Source string `json:"source"`
|
||||
PageName string `json:"page_name"`
|
||||
TagsJSON string `json:"tags_json"`
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 KBDB POST /blocks body(只放有值的欄位)
|
||||
body := make(map[string]interface{})
|
||||
body["content"] = input.Content
|
||||
if input.Type != "" {
|
||||
body["type"] = input.Type
|
||||
}
|
||||
if input.ParentID != "" {
|
||||
body["parent_id"] = input.ParentID
|
||||
}
|
||||
if input.UserID != "" {
|
||||
body["user_id"] = input.UserID
|
||||
}
|
||||
if input.Source != "" {
|
||||
body["source"] = input.Source
|
||||
}
|
||||
if input.PageName != "" {
|
||||
body["page_name"] = input.PageName
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
body["tags_json"] = input.TagsJSON
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte("POST")
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB POST request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
canonical_id: "kbdb_get"
|
||||
display_name: "KBDB 讀取"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx)
|
||||
block_id:
|
||||
type: string
|
||||
description: 模式 A — 取單一 block by id(指定後其他 filter 都忽略)
|
||||
page_name:
|
||||
type: string
|
||||
description: 模式 B — 按 page_name 查列表(可疊 type / source / user_id filter)
|
||||
type:
|
||||
type: string
|
||||
description: filter — block type(如 wiki-page / wiki-paragraph / triplet / note)
|
||||
source:
|
||||
type: string
|
||||
description: filter — block source(如 km-writer-direct / ai-canon-wiki)
|
||||
user_id:
|
||||
type: string
|
||||
description: filter — block user_id(如 inkstone_mira_tools)
|
||||
limit:
|
||||
type: integer
|
||||
description: 列表模式 limit,預設 50
|
||||
default: 50
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(選填,預設 https://kbdb.finally.click)
|
||||
default: "https://kbdb.finally.click"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
blocks:
|
||||
type: array
|
||||
description: page_name 模式回多個 block;block_id 模式回 1 個(仍包成陣列方便下游 foreach)
|
||||
items:
|
||||
type: object
|
||||
count:
|
||||
type: integer
|
||||
description: blocks.length
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 api_key"
|
||||
given: '{"page_name":"x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "沒給任何查詢條件"
|
||||
given: '{"api_key":"pk_live_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "用 type filter 列 wiki-page"
|
||||
given: '{"api_key":"ak_x","type":"wiki-page"}'
|
||||
then_contains: 'blocks'
|
||||
tags: [data, storage, kbdb, get, query, filter, primitive]
|
||||
description: "從 KBDB 讀 block。三種模式:(A) block_id 取單一;(B) page_name 取列表(可疊 filter);(C) 純 type / source / user_id filter(不需 page_name)。透過 host function http_request 呼叫 KBDB GET /blocks 或 /blocks/:id。Mira wiki_synthesis / feed watcher / 各 source workflow 都走這條。"
|
||||
config_example: |
|
||||
read_schema: # 模式 A — 單一 block by id
|
||||
api_key: "{{api_key}}"
|
||||
block_id: "26c51776-d07c-4490-9836-95e554d64549"
|
||||
read_drafts: # 模式 B — page_name 列表
|
||||
api_key: "{{api_key}}"
|
||||
page_name: "{{prev.entity_name}}"
|
||||
limit: 100
|
||||
list_unprocessed_raws: # 模式 C — 純 filter(mira_feed_watcher 用)
|
||||
api_key: "{{api_key}}"
|
||||
source: "km-writer-direct"
|
||||
type: "note"
|
||||
user_id: "inkstone_mira_post"
|
||||
limit: 50
|
||||
@@ -1,211 +0,0 @@
|
||||
// kbdb_get — 從 KBDB 讀 block(GET /blocks?page_name=... 或 GET /blocks/:id)
|
||||
// thin wrapper:透過 host function http_request 呼叫 KBDB API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
BlockID string `json:"block_id"` // 模式 A:單一 block by id(最優先)
|
||||
PageName string `json:"page_name"` // 模式 B:page_name 為主,可疊 type/source/user_id filter
|
||||
// 模式 C:純 filter 查詢,至少要有 type / source / user_id 其中一個(page_name 不必填)
|
||||
Type string `json:"type"` // 例:wiki-page / wiki-paragraph / triplet / note
|
||||
Source string `json:"source"` // 例:km-writer-direct / ai-canon-wiki
|
||||
UserID string `json:"user_id"` // 例:inkstone_mira_tools
|
||||
Limit int `json:"limit"` // optional, default 50
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// urlEncode:簡易 query string encoder(只處理 KBDB 會用到的字元)
|
||||
// 避免引入 net/url(白名單外)
|
||||
func urlEncode(s string) string {
|
||||
var out []byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
out = append(out, c)
|
||||
} else {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out = append(out, '%', hex[c>>4], hex[c&0x0f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
// 至少要有一個查詢條件
|
||||
hasFilter := input.Type != "" || input.Source != "" || input.UserID != ""
|
||||
if input.BlockID == "" && input.PageName == "" && !hasFilter {
|
||||
writeError("至少要有一個查詢條件:block_id / page_name / type / source / user_id")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
limit := input.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
// 構造 URL:block_id 模式走 /blocks/:id(單一),其餘走 /blocks?...query 列表
|
||||
var url string
|
||||
if input.BlockID != "" {
|
||||
url = kbdbURL + "/blocks/" + urlEncode(input.BlockID)
|
||||
} else {
|
||||
// list 模式:page_name / type / source / user_id 任意組合
|
||||
params := []string{}
|
||||
if input.PageName != "" {
|
||||
params = append(params, "page_name="+urlEncode(input.PageName))
|
||||
}
|
||||
if input.Type != "" {
|
||||
params = append(params, "type="+urlEncode(input.Type))
|
||||
}
|
||||
if input.Source != "" {
|
||||
params = append(params, "source="+urlEncode(input.Source))
|
||||
}
|
||||
if input.UserID != "" {
|
||||
params = append(params, "user_id="+urlEncode(input.UserID))
|
||||
}
|
||||
params = append(params, "limit="+strconv.Itoa(limit))
|
||||
|
||||
url = kbdbURL + "/blocks?"
|
||||
for i, p := range params {
|
||||
if i > 0 {
|
||||
url += "&"
|
||||
}
|
||||
url += p
|
||||
}
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
method := "GET"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 1<<20) // 1MB(list 可能很大)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLenU := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(nil)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLenU,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB GET request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
|
||||
// 解析回應
|
||||
if input.BlockID != "" {
|
||||
// 單一 block:KBDB 直接回 block 物件,包成 array 給下游 foreach
|
||||
var block map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &block); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
if _, hasErr := block["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false, "error": block["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"blocks": []map[string]interface{}{block},
|
||||
"count": 1,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
// page_name 列表模式:KBDB 回 {"blocks": [...], "count": N}
|
||||
var listResp struct {
|
||||
Blocks []map[string]interface{} `json:"blocks"`
|
||||
Count int `json:"count"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(respStr), &listResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
if listResp.Error != nil {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false, "error": listResp.Error,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"blocks": listResp.Blocks,
|
||||
"count": listResp.Count,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
canonical_id: "kbdb_ingest"
|
||||
display_name: "KBDB 寫入"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, text, user_id]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx,後者為 arcrun OAuth 取得)
|
||||
text:
|
||||
type: string
|
||||
description: 要寫入的 block 內容
|
||||
user_id:
|
||||
type: string
|
||||
description: namespace prefix(partner key 必須對應同一 namespace)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記(例如 km-writer / rss-tech-news / telegram)
|
||||
page_name:
|
||||
type: string
|
||||
description: 頁面名稱(選填)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(選填,預設 https://kbdb.finally.click)
|
||||
default: "https://kbdb.finally.click"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳原始物件(含 blocks_injected 等)
|
||||
error:
|
||||
type: string
|
||||
description: 錯誤訊息(success=false 時)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 text"
|
||||
given: '{"api_key":"pk_live_x","user_id":"ns_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"text":"x","user_id":"ns_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "正確寫入"
|
||||
given: '{"api_key":"pk_live_xxx","text":"hello","user_id":"inkstone_test","source":"smoke"}'
|
||||
then_contains: '{"success":true'
|
||||
tags: [data, storage, kbdb, ingest, primitive]
|
||||
description: "把單一 block 寫入 KBDB(POST /blocks/ingest),硬編碼 skip_llm=true(不觸發 LLM triplet 抽取)。Mira 等定型貼文場景使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
ingest_block: # 節點名稱(可自訂)
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
text: "{{previous_node.output.content}}"
|
||||
user_id: "inkstone_leo"
|
||||
source: "rss-tech-news"
|
||||
@@ -1,3 +0,0 @@
|
||||
module kbdb_ingest
|
||||
|
||||
go 1.21
|
||||
@@ -1,155 +0,0 @@
|
||||
// kbdb_ingest — 把 input 寫入 KBDB(POST /blocks/ingest)
|
||||
// thin wrapper:透過 host function http_request 呼叫 KBDB API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional, default https://kbdb.finally.click
|
||||
APIKey string `json:"api_key"` // 必填(pk_live_xxx 或 ak_xxx)
|
||||
Text string `json:"text"` // 必填(block 內容)
|
||||
UserID string `json:"user_id"` // 必填(namespace prefix 對應)
|
||||
Source string `json:"source"` // optional
|
||||
PageName string `json:"page_name"` // optional
|
||||
// 註:本零件硬編碼 skip_llm=true(mira 場景定型貼文,不需 KBDB triplet 抽取)。
|
||||
// 若需 LLM 抽取,未來另建 kbdb_ingest_with_llm 零件。
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Text == "" {
|
||||
writeError("text 必填")
|
||||
return
|
||||
}
|
||||
if input.UserID == "" {
|
||||
writeError("user_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 KBDB ingest 的 body(只含 KBDB 認得的欄位)
|
||||
type ingestBody struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
Source string `json:"source,omitempty"`
|
||||
PageName string `json:"page_name,omitempty"`
|
||||
SkipLLM *bool `json:"skip_llm,omitempty"`
|
||||
}
|
||||
skipLLM := true
|
||||
body := ingestBody{
|
||||
Text: input.Text,
|
||||
UserID: input.UserID,
|
||||
Source: input.Source,
|
||||
PageName: input.PageName,
|
||||
SkipLLM: &skipLLM,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks/ingest"
|
||||
method := "POST"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB ingest request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
// KBDB 回傳格式:{"blocks_injected": N, "triplets_injected": M, ...}
|
||||
respStr := string(outBuf[:outLen])
|
||||
|
||||
// 嘗試 parse 確認是 JSON(若 KBDB 回 error 也透傳)
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
// 若 KBDB 回 error 欄位(401/400 etc.),透傳
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
canonical_id: "kbdb_patch_block"
|
||||
display_name: "KBDB Block 部分更新"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, block_id]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx)
|
||||
block_id:
|
||||
type: string
|
||||
description: 要更新的 block UUID
|
||||
content:
|
||||
type: string
|
||||
description: 新內容(傳入則覆寫;不傳則不動)
|
||||
tags:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: tags 陣列(完整覆寫;不傳則不動)
|
||||
refs:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: refs 陣列(完整覆寫;不傳則不動)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記(傳入則覆寫)
|
||||
metadata_json:
|
||||
type: object
|
||||
description: 任意附加資料(完整覆寫)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
default: "https://kbdb.finally.click"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳的更新後 block
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 block_id"
|
||||
given: '{"api_key":"pk_live_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "至少要一個欄位"
|
||||
given: '{"api_key":"pk_live_x","block_id":"b_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "改 content"
|
||||
given: '{"api_key":"pk_live_x","block_id":"b_x","content":"new"}'
|
||||
then_contains: 'success'
|
||||
tags: [data, storage, kbdb, patch, edit, primitive]
|
||||
description: "PATCH 一個既有 KBDB block 的欄位(content / tags / refs / source / metadata_json)。透過 host function 呼叫 KBDB PATCH /blocks/:id。Mira 前端 inline edit 與 AI 自我修正使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
patch_block:
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
block_id: "{{previous_node.output.block_id}}"
|
||||
content: "新內容"
|
||||
tags: ["news", "ai"]
|
||||
@@ -1,3 +0,0 @@
|
||||
module kbdb_patch_block
|
||||
|
||||
go 1.21
|
||||
@@ -1,155 +0,0 @@
|
||||
// kbdb_patch_block — PATCH 一個既有 block 的部分欄位
|
||||
// 對應 KBDB endpoint: PATCH /blocks/{id}
|
||||
// SDD: matrix/kbdb/.agents/specs/blocks-edit-api/design.md §2
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
BlockID string `json:"block_id"` // 必填
|
||||
Content *string `json:"content"` // optional(pointer 區分「未傳」vs「設空字串」)
|
||||
Tags []string `json:"tags"` // optional 完整覆寫
|
||||
Refs []string `json:"refs"` // optional 完整覆寫
|
||||
Source *string `json:"source"` // optional
|
||||
Metadata map[string]interface{} `json:"metadata_json"` // optional
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.BlockID == "" {
|
||||
writeError("block_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
// 至少要有一個欄位(避免 KBDB 回 400)
|
||||
if input.Content == nil && input.Tags == nil && input.Refs == nil &&
|
||||
input.Source == nil && input.Metadata == nil {
|
||||
writeError("至少要傳一個更新欄位(content / tags / refs / source / metadata_json)")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 PATCH body:只放有值的欄位(pointer 控制)
|
||||
body := make(map[string]interface{})
|
||||
if input.Content != nil {
|
||||
body["content"] = *input.Content
|
||||
}
|
||||
if input.Tags != nil {
|
||||
body["tags"] = input.Tags
|
||||
}
|
||||
if input.Refs != nil {
|
||||
body["refs"] = input.Refs
|
||||
}
|
||||
if input.Source != nil {
|
||||
body["source"] = *input.Source
|
||||
}
|
||||
if input.Metadata != nil {
|
||||
body["metadata_json"] = input.Metadata
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks/" + input.BlockID
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte("PATCH")
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB PATCH request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
canonical_id: "line_notify"
|
||||
display_name: "LINE Notify"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [message, token]
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: 要發送的訊息
|
||||
token:
|
||||
type: string
|
||||
description: LINE Notify Channel Access Token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 token"
|
||||
given: '{"message":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 message"
|
||||
given: '{"token":"mytoken"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, line, notify, message]
|
||||
description: "發送 LINE Notify 訊息。透過 host function 呼叫 LINE Notify API,需要 Channel Access Token。"
|
||||
credentials_required:
|
||||
- key: line_token
|
||||
type: line_token
|
||||
description: "LINE Notify Channel Access Token"
|
||||
inject_as: token
|
||||
config_example: |
|
||||
send_line: # 節點名稱(可自訂)
|
||||
message: "" # 要發送的訊息(必填)
|
||||
# token 由 credentials.yaml 的 line_token 自動注入
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,114 +0,0 @@
|
||||
// line_notify — 發送 LINE Notify 訊息
|
||||
// 透過 host function 呼叫 LINE Notify API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Message == "" {
|
||||
writeError("message 必填")
|
||||
return
|
||||
}
|
||||
if input.Token == "" {
|
||||
writeError("token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
apiURL := "https://notify-api.line.me/api/notify"
|
||||
method := "POST"
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.Token,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
// form-encoded body
|
||||
body := "message=" + urlEncode(input.Message)
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
outBuf := make([]byte, 4096)
|
||||
var outLen uint32
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
uintptr(unsafe.Pointer(&bodyBytes[0])), uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("LINE Notify API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"status": 200},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// urlEncode — 簡易 URL 編碼(只處理常見字元)
|
||||
func urlEncode(s string) string {
|
||||
var sb strings.Builder
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9',
|
||||
c == '-', c == '_', c == '.', c == '~':
|
||||
sb.WriteRune(c)
|
||||
case c == ' ':
|
||||
sb.WriteByte('+')
|
||||
default:
|
||||
// UTF-8 encode
|
||||
buf := []byte(string(c))
|
||||
for _, b := range buf {
|
||||
sb.WriteByte('%')
|
||||
sb.WriteByte("0123456789ABCDEF"[b>>4])
|
||||
sb.WriteByte("0123456789ABCDEF"[b&0xf])
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
canonical_id: "telegram"
|
||||
display_name: "Telegram Bot"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [chat_id, text, bot_token]
|
||||
properties:
|
||||
chat_id:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
bot_token:
|
||||
type: string
|
||||
description: Telegram Bot Token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 bot_token"
|
||||
given: '{"chat_id":"123","text":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 chat_id"
|
||||
given: '{"text":"hello","bot_token":"token"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, telegram, bot, message]
|
||||
description: "透過 Telegram Bot 發送訊息。透過 host function 呼叫 Telegram Bot API,需要 bot_token。"
|
||||
credentials_required:
|
||||
- key: telegram_bot_token
|
||||
type: telegram_bot_token
|
||||
description: "Telegram Bot Token(由 @BotFather 取得)"
|
||||
inject_as: bot_token
|
||||
config_example: |
|
||||
send_message: # 節點名稱(可自訂)
|
||||
chat_id: "" # Telegram Chat ID(必填)
|
||||
text: "" # 訊息內文(必填)
|
||||
# bot_token 由 credentials.yaml 的 telegram_bot_token 自動注入
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,103 +0,0 @@
|
||||
// telegram — 透過 Telegram Bot 發送訊息
|
||||
// 透過 host function 呼叫 Telegram Bot API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
BotToken string `json:"bot_token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.ChatID == "" {
|
||||
writeError("chat_id 必填")
|
||||
return
|
||||
}
|
||||
if input.Text == "" {
|
||||
writeError("text 必填")
|
||||
return
|
||||
}
|
||||
if input.BotToken == "" {
|
||||
writeError("bot_token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
apiURL := "https://api.telegram.org/bot" + input.BotToken + "/sendMessage"
|
||||
method := "POST"
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
bodyData, _ := json.Marshal(map[string]string{
|
||||
"chat_id": input.ChatID,
|
||||
"text": input.Text,
|
||||
})
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := bodyData
|
||||
|
||||
outBuf := make([]byte, 4096)
|
||||
var outLen uint32
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
uintptr(unsafe.Pointer(&bodyBytes[0])), uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Telegram API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
var responseData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &responseData); err != nil {
|
||||
responseData = map[string]interface{}{"raw": responseStr}
|
||||
}
|
||||
|
||||
ok, _ := responseData["ok"].(bool)
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"ok": ok},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
Reference in New Issue
Block a user