diff --git a/docs/assets/tencent-qq-bot-openapi-v2-chat-verification.png b/docs/assets/tencent-qq-bot-openapi-v2-chat-verification.png new file mode 100644 index 00000000..db5f4b69 Binary files /dev/null and b/docs/assets/tencent-qq-bot-openapi-v2-chat-verification.png differ diff --git a/services/bin/dsensor.js b/services/bin/dsensor.js new file mode 100755 index 00000000..76e9e4e0 --- /dev/null +++ b/services/bin/dsensor.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../chaitin__dsensor_ds-s_h_40-25.07.001/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../chaitin__dsensor_ds-s_h_40-25.07.001/bin/dsensor.js", import.meta.url)), +}); diff --git a/services/bin/fofa-network-space-mapper.js b/services/bin/fofa-network-space-mapper.js new file mode 100755 index 00000000..7ae4b55b --- /dev/null +++ b/services/bin/fofa-network-space-mapper.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../fofa__network-space-mapper/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../fofa__network-space-mapper/bin/fofa-network-space-mapper.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 4124b804..bb8ad04c 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -9,14 +9,6 @@ const services = { entryFile: "../alibaba-cloud__simple-application-server-firewall/bin/alibaba-cloud-simple-application-server-firewall.js", serviceModule: "../alibaba-cloud__simple-application-server-firewall/src/service.js", }, - "safeline-waf": { - entryFile: "../chaitin__safeline-waf/bin/safeline-waf.js", - serviceModule: "../chaitin__safeline-waf/src/service.js", - }, - "safeline-waf-eliminate-false-positive": { - entryFile: "../chaitin__safeline-waf-eliminate-false-positive/bin/safeline-waf-eliminate-false-positive.js", - serviceModule: "../chaitin__safeline-waf-eliminate-false-positive/src/service.js", - }, "cloudatlas": { entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", serviceModule: "../chaitin__cloudatlas/src/service.js", @@ -53,6 +45,10 @@ const services = { entryFile: "../dptech__umc-ads_v5-3-29/bin/dptech-umc-ads-v5-3-29.js", serviceModule: "../dptech__umc-ads_v5-3-29/src/service.js", }, + "dsensor": { + entryFile: "../chaitin__dsensor_ds-s_h_40-25.07.001/bin/dsensor.js", + serviceModule: "../chaitin__dsensor_ds-s_h_40-25.07.001/src/service.js", + }, "elastic-kibana-7-17-26": { entryFile: "../elastic__kibana_7-17-26/bin/elastic-kibana-7-17-26.js", serviceModule: "../elastic__kibana_7-17-26/src/service.js", @@ -65,6 +61,10 @@ const services = { entryFile: "../first__epss-v1/bin/first-epss-v1.js", serviceModule: "../first__epss-v1/src/service.js", }, + "fofa-network-space-mapper": { + entryFile: "../fofa__network-space-mapper/bin/fofa-network-space-mapper.js", + serviceModule: "../fofa__network-space-mapper/src/service.js", + }, "fortinet-fw": { entryFile: "../fortinet__fw/bin/fortinet-fw.js", serviceModule: "../fortinet__fw/src/service.js", @@ -89,14 +89,14 @@ const services = { entryFile: "../hillstone__fw_v5-5-r6/bin/hillstone-fw-v5-5-r6.js", serviceModule: "../hillstone__fw_v5-5-r6/src/service.js", }, - "huorong-endpoint-security-management-system-v2-0-19-3": { - entryFile: "../huorong__endpoint-security-management-system_v2-0-19-3/bin/huorong-endpoint-security-management-system-v2-0-19-3.js", - serviceModule: "../huorong__endpoint-security-management-system_v2-0-19-3/src/service.js", - }, "huawei-fw-usg6000e": { entryFile: "../huawei__fw-usg6000e/bin/huawei-fw-usg6000e.js", serviceModule: "../huawei__fw-usg6000e/src/service.js", }, + "huorong-endpoint-security-management-system-v2-0-19-3": { + entryFile: "../huorong__endpoint-security-management-system_v2-0-19-3/bin/huorong-endpoint-security-management-system-v2-0-19-3.js", + serviceModule: "../huorong__endpoint-security-management-system_v2-0-19-3/src/service.js", + }, "imperva-waf-gateway-v13-6-90": { entryFile: "../imperva__waf-gateway_v13-6-90/bin/imperva-waf-gateway-v13-6-90.js", serviceModule: "../imperva__waf-gateway_v13-6-90/src/service.js", @@ -105,10 +105,18 @@ const services = { entryFile: "../nsfocus__ads_v4-5-r90-f06/bin/nsfocus-ads-v4-5-r90-f06.js", serviceModule: "../nsfocus__ads_v4-5-r90-f06/src/service.js", }, + "nsfocus-ngfw-v60-9900": { + entryFile: "../nsfocus__ngfw_v60-9900/bin/nsfocus-ngfw-v60-9900.js", + serviceModule: "../nsfocus__ngfw_v60-9900/src/service.js", + }, "nsfocus-nips-v5-6-r11": { entryFile: "../nsfocus__nips_v5-6-r11/bin/nsfocus-nips-v5-6-r11.js", serviceModule: "../nsfocus__nips_v5-6-r11/src/service.js", }, + "opencti": { + entryFile: "../filigran__opencti/bin/opencti.js", + serviceModule: "../filigran__opencti/src/service.js", + }, "owasp-dependency-track-sca-v5-0": { entryFile: "../owasp__dependency-track-sca_v5-0/bin/owasp-dependency-track-sca-v5-0.js", serviceModule: "../owasp__dependency-track-sca_v5-0/src/service.js", @@ -149,6 +157,18 @@ const services = { entryFile: "../riversafe__waf/bin/riversafe-waf.js", serviceModule: "../riversafe__waf/src/service.js", }, + "ruijie-behavior-firewall-r2-3-2-t0": { + entryFile: "../ruijie__behavior_firewall_r2-3-2-t0/bin/ruijie-behavior-firewall-r2-3-2-t0.js", + serviceModule: "../ruijie__behavior_firewall_r2-3-2-t0/src/service.js", + }, + "safeline-waf": { + entryFile: "../chaitin__safeline-waf/bin/safeline-waf.js", + serviceModule: "../chaitin__safeline-waf/src/service.js", + }, + "safeline-waf-eliminate-false-positive": { + entryFile: "../chaitin__safeline-waf-eliminate-false-positive/bin/safeline-waf-eliminate-false-positive.js", + serviceModule: "../chaitin__safeline-waf-eliminate-false-positive/src/service.js", + }, "sangfor-fw-v8-0-45": { entryFile: "../sangfor__fw_v8-0-45/bin/sangfor-fw-v8-0-45.js", serviceModule: "../sangfor__fw_v8-0-45/src/service.js", @@ -157,13 +177,21 @@ const services = { entryFile: "../sangfor__xdr_v2-0-45/bin/sangfor-xdr-v2-0-45.js", serviceModule: "../sangfor__xdr_v2-0-45/src/service.js", }, + "skycloud-inet": { + entryFile: "../skycloud__inet/bin/skycloud-inet.js", + serviceModule: "../skycloud__inet/src/service.js", + }, "slack-group-robot": { entryFile: "../slack__group-robot/bin/slack-group-robot.js", serviceModule: "../slack__group-robot/src/service.js", }, - "skycloud-inet": { - entryFile: "../skycloud__inet/bin/skycloud-inet.js", - serviceModule: "../skycloud__inet/src/service.js", + "telegram-bot-api": { + entryFile: "../telegram__bot-api/bin/telegram-bot-api.js", + serviceModule: "../telegram__bot-api/src/service.js", + }, + "tencent-qq-chat": { + entryFile: "../tencent__qq-chat/bin/tencent-qq-chat.js", + serviceModule: "../tencent__qq-chat/src/service.js", }, "tencent-qyweixin-group-robot": { entryFile: "../tencent__qyweixin-group-robot/bin/tencent-qyweixin-group-robot.js", @@ -177,18 +205,14 @@ const services = { entryFile: "../tencent__tsec_v2-5-1/bin/tencent-tsec-v2-5-1.js", serviceModule: "../tencent__tsec_v2-5-1/src/service.js", }, - "telegram-bot-api": { - entryFile: "../telegram__bot-api/bin/telegram-bot-api.js", - serviceModule: "../telegram__bot-api/src/service.js", + "threatbook-claudsandbox-v3": { + entryFile: "../threatbook__claudsandbox_v3/bin/threatbook-claudsandbox-v3.js", + serviceModule: "../threatbook__claudsandbox_v3/src/service.js", }, "threatbook-cloudapi-v3": { entryFile: "../threatbook__cloudapi_v3/bin/threatbook-cloudapi-v3.js", serviceModule: "../threatbook__cloudapi_v3/src/service.js", }, - "threatbook-claudsandbox-v3": { - entryFile: "../threatbook__claudsandbox_v3/bin/threatbook-claudsandbox-v3.js", - serviceModule: "../threatbook__claudsandbox_v3/src/service.js", - }, "threatbook-ngtip-v5": { entryFile: "../threatbook__ngtip_v5/bin/threatbook-ngtip-v5.js", serviceModule: "../threatbook__ngtip_v5/src/service.js", @@ -205,10 +229,6 @@ const services = { entryFile: "../threatbook__tip_v4/bin/threatbook-tip-v4.js", serviceModule: "../threatbook__tip_v4/src/service.js", }, - "topsec-waf-v3-2294-20238": { - entryFile: "../topsec__waf_v3-2294-20238/bin/topsec-waf-v3-2294-20238.js", - serviceModule: "../topsec__waf_v3-2294-20238/src/service.js", - }, "topsec-fw-2u": { entryFile: "../topsec__fw-2u/bin/topsec-fw-2u.js", serviceModule: "../topsec__fw-2u/src/service.js", @@ -221,6 +241,10 @@ const services = { entryFile: "../topsec__fw_v3-7-6/bin/topsec-fw-v3-7-6.js", serviceModule: "../topsec__fw_v3-7-6/src/service.js", }, + "topsec-waf-v3-2294-20238": { + entryFile: "../topsec__waf_v3-2294-20238/bin/topsec-waf-v3-2294-20238.js", + serviceModule: "../topsec__waf_v3-2294-20238/src/service.js", + }, "venus-ads-v3-6": { entryFile: "../venus__ads_v3-6/bin/venus-ads-v3-6.js", serviceModule: "../venus__ads_v3-6/src/service.js", @@ -237,22 +261,6 @@ const services = { entryFile: "../wd__k01/bin/wd-k01.js", serviceModule: "../wd__k01/src/service.js", }, - "tencent-bh": { - entryFile: "../tencent__bh/bin/tencent-bh.js", - serviceModule: "../tencent__bh/src/service.js", - }, - "alibaba-sas": { - entryFile: "../alibaba__sas/bin/alibaba-sas.js", - serviceModule: "../alibaba__sas/src/service.js", - }, - "misp": { - entryFile: "../misp__misp/bin/misp.js", - serviceModule: "../misp__misp/src/service.js", - }, - "opencti": { - entryFile: "../filigran__opencti/bin/opencti.js", - serviceModule: "../filigran__opencti/src/service.js", - }, }; const serviceNames = Object.keys(services); diff --git a/services/bin/opencti.js b/services/bin/opencti.js old mode 100644 new mode 100755 diff --git a/services/bin/ruijie-behavior-firewall-r2-3-2-t0.js b/services/bin/ruijie-behavior-firewall-r2-3-2-t0.js new file mode 100755 index 00000000..b369f1b4 --- /dev/null +++ b/services/bin/ruijie-behavior-firewall-r2-3-2-t0.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../ruijie__behavior_firewall_r2-3-2-t0/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../ruijie__behavior_firewall_r2-3-2-t0/bin/ruijie-behavior-firewall-r2-3-2-t0.js", import.meta.url)), +}); diff --git a/services/bin/tencent-qq-chat.js b/services/bin/tencent-qq-chat.js new file mode 100755 index 00000000..b0fc27b1 --- /dev/null +++ b/services/bin/tencent-qq-chat.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../tencent__qq-chat/src/service.js"; +import { maybeAutoStartGatewayFromCli } from "../tencent__qq-chat/src/tencent-qq-chat.js"; + +await maybeAutoStartGatewayFromCli(); +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../tencent__qq-chat/bin/tencent-qq-chat.js", import.meta.url)), +}); diff --git a/services/bin/volcengine-cloud-firewall.js b/services/bin/volcengine-cloud-firewall.js old mode 100644 new mode 100755 diff --git a/services/fofa__network-space-mapper/bin/fofa-network-space-mapper.js b/services/fofa__network-space-mapper/bin/fofa-network-space-mapper.js old mode 100644 new mode 100755 diff --git a/services/package.json b/services/package.json index 2fbea088..ce537ef7 100644 --- a/services/package.json +++ b/services/package.json @@ -15,20 +15,24 @@ "dptech-eds": "bin/dptech-eds.js", "dptech-fw-v4-6-10": "bin/dptech-fw-v4-6-10.js", "dptech-umc-ads-v5-3-29": "bin/dptech-umc-ads-v5-3-29.js", + "dsensor": "bin/dsensor.js", "elastic-kibana-7-17-26": "bin/elastic-kibana-7-17-26.js", "feishu-group-robot": "bin/feishu-group-robot.js", "first-epss-v1": "bin/first-epss-v1.js", + "fofa-network-space-mapper": "bin/fofa-network-space-mapper.js", "fortinet-fw": "bin/fortinet-fw.js", "fortinet-waf": "bin/fortinet-waf.js", "hermes-gateway": "bin/hermes-gateway.js", + "hillstone-fw-v5-5-r10": "bin/hillstone-fw-v5-5-r10.js", "hillstone-fw-v5-5-r4": "bin/hillstone-fw-v5-5-r4.js", "hillstone-fw-v5-5-r6": "bin/hillstone-fw-v5-5-r6.js", - "hillstone-fw-v5-5-r10": "bin/hillstone-fw-v5-5-r10.js", - "huorong-endpoint-security-management-system-v2-0-19-3": "bin/huorong-endpoint-security-management-system-v2-0-19-3.js", "huawei-fw-usg6000e": "bin/huawei-fw-usg6000e.js", + "huorong-endpoint-security-management-system-v2-0-19-3": "bin/huorong-endpoint-security-management-system-v2-0-19-3.js", "imperva-waf-gateway-v13-6-90": "bin/imperva-waf-gateway-v13-6-90.js", "nsfocus-ads-v4-5-r90-f06": "bin/nsfocus-ads-v4-5-r90-f06.js", + "nsfocus-ngfw-v60-9900": "bin/nsfocus-ngfw-v60-9900.js", "nsfocus-nips-v5-6-r11": "bin/nsfocus-nips-v5-6-r11.js", + "opencti": "bin/opencti.js", "owasp-dependency-track-sca-v5-0": "bin/owasp-dependency-track-sca-v5-0.js", "panabit-tang-r1": "bin/panabit-tang-r1.js", "qianxin-fw-secgate3600": "bin/qianxin-fw-secgate3600.js", @@ -39,34 +43,32 @@ "qingteng-hids-v5": "bin/qingteng-hids-v5.js", "ray-waf-v6-1-2": "bin/ray-waf-v6-1-2.js", "riversafe-waf": "bin/riversafe-waf.js", + "ruijie-behavior-firewall-r2-3-2-t0": "bin/ruijie-behavior-firewall-r2-3-2-t0.js", "safeline-waf": "bin/safeline-waf.js", "safeline-waf-eliminate-false-positive": "bin/safeline-waf-eliminate-false-positive.js", "sangfor-fw-v8-0-45": "bin/sangfor-fw-v8-0-45.js", "sangfor-xdr-v2-0-45": "bin/sangfor-xdr-v2-0-45.js", "skycloud-inet": "bin/skycloud-inet.js", "slack-group-robot": "bin/slack-group-robot.js", + "telegram-bot-api": "bin/telegram-bot-api.js", + "tencent-qq-chat": "bin/tencent-qq-chat.js", "tencent-qyweixin-group-robot": "bin/tencent-qyweixin-group-robot.js", "tencent-tix-saas": "bin/tencent-tix-saas.js", "tencent-tsec-v2-5-1": "bin/tencent-tsec-v2-5-1.js", - "telegram-bot-api": "bin/telegram-bot-api.js", - "threatbook-cloudapi-v3": "bin/threatbook-cloudapi-v3.js", "threatbook-claudsandbox-v3": "bin/threatbook-claudsandbox-v3.js", + "threatbook-cloudapi-v3": "bin/threatbook-cloudapi-v3.js", "threatbook-ngtip-v5": "bin/threatbook-ngtip-v5.js", "threatbook-onesig": "bin/threatbook-onesig.js", "threatbook-tdp": "bin/threatbook-tdp.js", "threatbook-tip-v4": "bin/threatbook-tip-v4.js", + "topsec-fw-2u": "bin/topsec-fw-2u.js", "topsec-fw-5u": "bin/topsec-fw-5u.js", "topsec-fw-v3-7-6": "bin/topsec-fw-v3-7-6.js", - "topsec-fw-2u": "bin/topsec-fw-2u.js", "topsec-waf-v3-2294-20238": "bin/topsec-waf-v3-2294-20238.js", "venus-ads-v3-6": "bin/venus-ads-v3-6.js", "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", - "wd-k01": "bin/wd-k01.js", - "tencent-bh": "bin/tencent-bh.js", - "alibaba-sas": "bin/alibaba-sas.js", - "misp": "bin/misp.js", - "opencti": "bin/opencti.js" + "wd-k01": "bin/wd-k01.js" }, "files": [ "bin/alibaba-cloud-simple-application-server-firewall.js", @@ -79,20 +81,24 @@ "bin/dptech-eds.js", "bin/dptech-fw-v4-6-10.js", "bin/dptech-umc-ads-v5-3-29.js", + "bin/dsensor.js", "bin/elastic-kibana-7-17-26.js", "bin/feishu-group-robot.js", "bin/first-epss-v1.js", + "bin/fofa-network-space-mapper.js", "bin/fortinet-fw.js", "bin/fortinet-waf.js", "bin/hermes-gateway.js", + "bin/hillstone-fw-v5-5-r10.js", "bin/hillstone-fw-v5-5-r4.js", "bin/hillstone-fw-v5-5-r6.js", - "bin/hillstone-fw-v5-5-r10.js", - "bin/huorong-endpoint-security-management-system-v2-0-19-3.js", "bin/huawei-fw-usg6000e.js", + "bin/huorong-endpoint-security-management-system-v2-0-19-3.js", "bin/imperva-waf-gateway-v13-6-90.js", "bin/nsfocus-ads-v4-5-r90-f06.js", + "bin/nsfocus-ngfw-v60-9900.js", "bin/nsfocus-nips-v5-6-r11.js", + "bin/opencti.js", "bin/owasp-dependency-track-sca-v5-0.js", "bin/panabit-tang-r1.js", "bin/qianxin-fw-secgate3600.js", @@ -103,18 +109,20 @@ "bin/qingteng-hids-v5.js", "bin/ray-waf-v6-1-2.js", "bin/riversafe-waf.js", - "bin/skycloud-inet.js", - "bin/slack-group-robot.js", - "bin/safeline-waf-eliminate-false-positive.js", + "bin/ruijie-behavior-firewall-r2-3-2-t0.js", "bin/safeline-waf.js", + "bin/safeline-waf-eliminate-false-positive.js", "bin/sangfor-fw-v8-0-45.js", "bin/sangfor-xdr-v2-0-45.js", + "bin/skycloud-inet.js", + "bin/slack-group-robot.js", + "bin/telegram-bot-api.js", + "bin/tencent-qq-chat.js", "bin/tencent-qyweixin-group-robot.js", "bin/tencent-tix-saas.js", "bin/tencent-tsec-v2-5-1.js", - "bin/telegram-bot-api.js", - "bin/threatbook-cloudapi-v3.js", "bin/threatbook-claudsandbox-v3.js", + "bin/threatbook-cloudapi-v3.js", "bin/threatbook-ngtip-v5.js", "bin/threatbook-onesig.js", "bin/threatbook-tdp.js", @@ -125,12 +133,8 @@ "bin/topsec-waf-v3-2294-20238.js", "bin/venus-ads-v3-6.js", "bin/volcengine-cloud-firewall.js", - "bin/wd-k01.js", - "bin/tencent-bh.js", - "bin/alibaba-sas.js", - "bin/misp.js", - "bin/opencti.js", "bin/wangsu-label-ip.js", + "bin/wd-k01.js", "alibaba-cloud__simple-application-server-firewall", "chaitin__cloudatlas", "das__gateway_v3", @@ -141,20 +145,24 @@ "dptech__eds", "dptech__fw_v4-6-10", "dptech__umc-ads_v5-3-29", + "chaitin__dsensor_ds-s_h_40-25.07.001", "elastic__kibana_7-17-26", "feishu__group-robot", "first__epss-v1", + "fofa__network-space-mapper", "fortinet__fw", "fortinet__waf", "hermes__gateway", + "hillstone__fw_v5-5-r10", "hillstone__fw_v5-5-r4", "hillstone__fw_v5-5-r6", - "hillstone__fw_v5-5-r10", - "huorong__endpoint-security-management-system_v2-0-19-3", "huawei__fw-usg6000e", + "huorong__endpoint-security-management-system_v2-0-19-3", "imperva__waf-gateway_v13-6-90", "nsfocus__ads_v4-5-r90-f06", + "nsfocus__ngfw_v60-9900", "nsfocus__nips_v5-6-r11", + "filigran__opencti", "owasp__dependency-track-sca_v5-0", "panabit__tang-r1", "qianxin__fw-secgate3600", @@ -165,18 +173,20 @@ "qingteng__hids_v5", "ray__waf_v6-1-2", "riversafe__waf", - "skycloud__inet", - "slack__group-robot", - "chaitin__safeline-waf-eliminate-false-positive", + "ruijie__behavior_firewall_r2-3-2-t0", "chaitin__safeline-waf", + "chaitin__safeline-waf-eliminate-false-positive", "sangfor__fw_v8-0-45", "sangfor__xdr_v2-0-45", + "skycloud__inet", + "slack__group-robot", + "telegram__bot-api", + "tencent__qq-chat", "tencent__qyweixin-group-robot", "tencent__tix-saas", "tencent__tsec_v2-5-1", - "telegram__bot-api", - "threatbook__cloudapi_v3", "threatbook__claudsandbox_v3", + "threatbook__cloudapi_v3", "threatbook__ngtip_v5", "threatbook__onesig", "threatbook__tdp", @@ -187,16 +197,14 @@ "topsec__waf_v3-2294-20238", "venus__ads_v3-6", "volcengine__cloud-firewall", - "wd__k01", - "tencent__bh", - "alibaba__sas", - "misp__misp", - "filigran__opencti", "wangsu__label-ip", + "wd__k01", "scripts", "bin/octobus-tentacles.js" ], "scripts": { + "registry:generate": "node scripts/generate-service-registry.mjs", + "registry:check": "node scripts/generate-service-registry.mjs --check", "validate": "node scripts/validate-service-package.mjs", "test": "node scripts/run-tests.mjs", "coverage:all": "node scripts/run-coverage-all.mjs", diff --git a/services/scripts/generate-service-registry.mjs b/services/scripts/generate-service-registry.mjs new file mode 100644 index 00000000..84bee492 --- /dev/null +++ b/services/scripts/generate-service-registry.mjs @@ -0,0 +1,410 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT_BIN_NAME = "octobus-tentacles"; +const ROOT_BIN_TARGET = "bin/octobus-tentacles.js"; +const SERVICE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const EXECUTABLE_MODE = 0o755; + +function readJSON(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error(`${filePath}: failed to read JSON: ${error.message}`); + } +} + +function writeJSON(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function filepathFromPackagePath(packagePath) { + return packagePath.split("/").join(path.sep); +} + +function parseArgs(argv) { + const opts = { + check: false, + root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--check") { + opts.check = true; + continue; + } + if (arg === "--root") { + if (i + 1 >= argv.length || argv[i + 1] === "") { + throw new Error("--root must not be empty"); + } + opts.root = argv[++i]; + continue; + } + if (arg.startsWith("--root=")) { + opts.root = arg.slice("--root=".length); + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + + if (opts.root === "") { + throw new Error("--root must not be empty"); + } + + return { + ...opts, + root: path.resolve(opts.root), + }; +} + +function isServiceDir(root, entry) { + if (!entry.isDirectory()) { + return false; + } + if (entry.name === "bin" || entry.name === "scripts" || entry.name === "tests" || entry.name === "node_modules") { + return false; + } + if (entry.name.startsWith(".")) { + return false; + } + return fs.existsSync(path.join(root, entry.name, "service.json")); +} + +export function discoverServices(root) { + const services = fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => isServiceDir(root, entry)) + .map((entry) => { + const dir = entry.name; + const manifest = readJSON(path.join(root, dir, "service.json")); + if (!SERVICE_NAME_RE.test(manifest.name ?? "")) { + throw new Error(`${dir}/service.json name "${manifest.name ?? ""}" must match ${SERVICE_NAME_RE}`); + } + return { + dir, + name: manifest.name, + rootBinTarget: `bin/${manifest.name}.js`, + serviceEntry: `${dir}/bin/${manifest.name}.js`, + serviceModule: `${dir}/src/service.js`, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const seen = new Map(); + for (const service of services) { + const previous = seen.get(service.name); + if (previous != null) { + throw new Error(`duplicate service name "${service.name}" in ${previous} and ${service.dir}`); + } + seen.set(service.name, service.dir); + } + + return services; +} + +function assertServiceFiles(root, services) { + const errors = []; + for (const service of services) { + for (const [label, relativePath] of [ + ["service entry", service.serviceEntry], + ["service module", service.serviceModule], + ]) { + const fullPath = path.join(root, filepathFromPackagePath(relativePath)); + let stat; + try { + stat = fs.statSync(fullPath); + } catch { + errors.push(`${service.dir} ${label} "${relativePath}" must exist`); + continue; + } + if (!stat.isFile()) { + errors.push(`${service.dir} ${label} "${relativePath}" must be a file`); + continue; + } + if (label === "service entry" && (stat.mode & 0o111) === 0) { + errors.push(`${service.dir} ${label} "${relativePath}" must be executable`); + } + } + } + return errors; +} + +function makeServiceEntriesExecutable(root, services) { + for (const service of services) { + const entryPath = path.join(root, filepathFromPackagePath(service.serviceEntry)); + if (!fs.existsSync(entryPath)) { + continue; + } + const stat = fs.statSync(entryPath); + if (stat.isFile() && (stat.mode & 0o111) === 0) { + fs.chmodSync(entryPath, EXECUTABLE_MODE); + } + } +} + +function desiredPackage(pkg, services) { + const next = { + ...pkg, + bin: { + [ROOT_BIN_NAME]: ROOT_BIN_TARGET, + }, + }; + + for (const service of services) { + next.bin[service.name] = service.rootBinTarget; + } + + next.files = [ + ...services.map((service) => service.rootBinTarget), + ...services.map((service) => service.dir), + "scripts", + ROOT_BIN_TARGET, + ]; + + return next; +} + +function desiredDispatcher(services) { + const serviceMap = services.map((service) => ` "${service.name}": { + entryFile: "../${service.serviceEntry}", + serviceModule: "../${service.serviceModule}", + },`).join("\n"); + + return `#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; +import { Command } from "commander"; + +const services = { +${serviceMap} +}; + +const serviceNames = Object.keys(services); + +const program = new Command(); + +program + .name("octobus-tentacles") + .usage(" [args]") + .description("Run a service from this package") + .argument("[service]", "service name") + .allowUnknownOption(true) + .allowExcessArguments(true) + .passThroughOptions() + .addHelpText("after", \` +Services: +\${serviceNames.map((name) => \` \${name.padEnd(37)}\`).join("\\n")} + +Use 'octobus-tentacles --help' to print service help.\`) + .action(async (serviceName) => { + if (!serviceName) { + program.outputHelp(); + return; + } + + const selected = services[serviceName]; + if (!selected) { + process.stderr.write(\`Unknown service: \${serviceName}\\n\\n\`); + program.outputHelp({ error: true }); + process.exitCode = 1; + return; + } + + const { service } = await import(new URL(selected.serviceModule, import.meta.url)); + + await runServiceMain(service, { + argv: program.args.slice(1), + entryFile: fileURLToPath(new URL(selected.entryFile, import.meta.url)), + }); + }); + +await program.parseAsync(); +`; +} + +function desiredWrapper(service) { + return `#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../${service.serviceModule}"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../${service.serviceEntry}", import.meta.url)), +}); +`; +} + +function isValidCustomWrapper(source, service) { + return source.includes("import { fileURLToPath } from \"node:url\";") + && source.includes("runServiceMain(service, {") + && source.includes(`../${service.serviceModule}`) + && source.includes(`entryFile: fileURLToPath(new URL("../${service.serviceEntry}", import.meta.url))`); +} + +function compareText(errors, label, actualPath, expected) { + if (!fs.existsSync(actualPath)) { + errors.push(`${label} must exist`); + return; + } + if (fs.readFileSync(actualPath, "utf8") !== expected) { + errors.push(`${label} is not up to date`); + } +} + +function compareJSON(errors, label, actualPath, expected) { + if (!fs.existsSync(actualPath)) { + errors.push(`${label} must exist`); + return; + } + const actual = readJSON(actualPath); + if (`${JSON.stringify(actual, null, 2)}\n` !== `${JSON.stringify(expected, null, 2)}\n`) { + errors.push(`${label} is not up to date`); + } +} + +function checkDuplicateFiles(errors, pkg) { + if (!Array.isArray(pkg.files)) { + errors.push("package.json files must be an array"); + return; + } + const seen = new Set(); + for (const entry of pkg.files) { + if (seen.has(entry)) { + errors.push(`package.json files contains duplicate entry "${entry}"`); + } + seen.add(entry); + } +} + +function checkExecutable(errors, filePath, label) { + if (!fs.existsSync(filePath)) { + errors.push(`${label} must exist`); + return; + } + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + errors.push(`${label} must be a file`); + return; + } + if ((stat.mode & 0o111) === 0) { + errors.push(`${label} must be executable`); + } +} + +function checkWrappers(errors, root, services) { + for (const service of services) { + const wrapperPath = path.join(root, filepathFromPackagePath(service.rootBinTarget)); + const expected = desiredWrapper(service); + if (!fs.existsSync(wrapperPath)) { + errors.push(`${service.rootBinTarget} must exist`); + continue; + } + checkExecutable(errors, wrapperPath, service.rootBinTarget); + const actual = fs.readFileSync(wrapperPath, "utf8"); + if (actual !== expected && !isValidCustomWrapper(actual, service)) { + errors.push(`${service.rootBinTarget} must point to ${service.dir}`); + } + } +} + +function updateWrapper(root, service) { + const wrapperPath = path.join(root, filepathFromPackagePath(service.rootBinTarget)); + const expected = desiredWrapper(service); + fs.mkdirSync(path.dirname(wrapperPath), { recursive: true }); + if (fs.existsSync(wrapperPath)) { + const actual = fs.readFileSync(wrapperPath, "utf8"); + if (actual !== expected && isValidCustomWrapper(actual, service)) { + fs.chmodSync(wrapperPath, EXECUTABLE_MODE); + return false; + } + } + fs.writeFileSync(wrapperPath, expected); + fs.chmodSync(wrapperPath, EXECUTABLE_MODE); + return true; +} + +export function checkRegistry(root) { + const services = discoverServices(root); + const errors = assertServiceFiles(root, services); + const packagePath = path.join(root, "package.json"); + const dispatcherPath = path.join(root, filepathFromPackagePath(ROOT_BIN_TARGET)); + const pkg = fs.existsSync(packagePath) ? readJSON(packagePath) : {}; + + compareJSON(errors, "package.json", packagePath, desiredPackage(pkg, services)); + checkDuplicateFiles(errors, pkg); + compareText(errors, ROOT_BIN_TARGET, dispatcherPath, desiredDispatcher(services)); + checkExecutable(errors, dispatcherPath, ROOT_BIN_TARGET); + checkWrappers(errors, root, services); + + return { errors }; +} + +export function generateRegistry(root) { + const services = discoverServices(root); + makeServiceEntriesExecutable(root, services); + const serviceFileErrors = assertServiceFiles(root, services); + if (serviceFileErrors.length > 0) { + throw new Error(serviceFileErrors.join("\n")); + } + + const packagePath = path.join(root, "package.json"); + if (!fs.existsSync(packagePath)) { + throw new Error(`${packagePath}: missing package.json`); + } + const pkg = readJSON(packagePath); + writeJSON(packagePath, desiredPackage(pkg, services)); + + const dispatcherPath = path.join(root, filepathFromPackagePath(ROOT_BIN_TARGET)); + fs.mkdirSync(path.dirname(dispatcherPath), { recursive: true }); + fs.writeFileSync(dispatcherPath, desiredDispatcher(services)); + fs.chmodSync(dispatcherPath, EXECUTABLE_MODE); + + let writtenWrappers = 0; + for (const service of services) { + if (updateWrapper(root, service)) { + writtenWrappers += 1; + } + } + + return { + services: services.length, + writtenWrappers, + }; +} + +export function main(argv = process.argv.slice(2)) { + const opts = parseArgs(argv); + if (opts.check) { + const { errors } = checkRegistry(opts.root); + if (errors.length > 0) { + for (const error of errors) { + console.error(`error: ${error}`); + } + return 1; + } + console.log("service registry is up to date"); + return 0; + } + + const result = generateRegistry(opts.root); + console.log(`generated service registry for ${result.services} services`); + if (result.writtenWrappers > 0) { + console.log(`updated ${result.writtenWrappers} root wrappers`); + } + return 0; +} + +const entrypoint = fileURLToPath(import.meta.url); +if (process.argv[1] != null && path.resolve(process.argv[1]) === entrypoint) { + try { + process.exitCode = main(); + } catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/services/tencent__qq-chat/README.md b/services/tencent__qq-chat/README.md new file mode 100644 index 00000000..cc2ac251 --- /dev/null +++ b/services/tencent__qq-chat/README.md @@ -0,0 +1,68 @@ +# Tencent QQ Chat + +This OctoBus service connects to QQ chat through QQ Bot OpenAPI v2. + +It covers the stable HTTP OpenAPI side: + +- fetch `AccessToken` from `https://bots.qq.com/app/getAppAccessToken` +- send C2C messages with `/v2/users/{openid}/messages` +- send group messages with `/v2/groups/{group_openid}/messages` +- keep a long-running WebSocket Gateway receive loop for C2C/group message events +- buffer incoming messages in memory for `PollMessages` +- normalize incoming webhook/websocket payloads for C2C, group, and channel message events + +The Gateway receiver subscribes to `GROUP_AND_C2C_EVENT` by default (`1 << 25`), which covers `C2C_MESSAGE_CREATE` and `GROUP_AT_MESSAGE_CREATE`. + +## Configuration + +```json +{ + "baseUrl": "https://api.sgroup.qq.com", + "tokenUrl": "https://bots.qq.com/app/getAppAccessToken", + "timeoutMs": 5000, + "autoStartGateway": true, + "gatewayIntents": 33554432, + "maxBufferedMessages": 1000, + "gatewayReconnectMs": 5000 +} +``` + +Secrets: + +```json +{ + "appId": "APPID", + "appSecret": "APPSECRET" +} +``` + +You may provide a pre-fetched `accessToken` instead of `appId` and `appSecret`. + +## Methods + +- `GetAccessToken`: fetches or returns the configured access token. +- `StartGateway`: starts the QQ Bot WebSocket Gateway receive loop. +- `StopGateway`: stops the Gateway receive loop. +- `GetGatewayStatus`: returns Gateway connection state and buffered message counts. +- `PollMessages`: returns buffered received message events. Set `ack` to remove returned messages immediately. +- `AckMessage`: removes buffered messages by `local_id`, or all messages when `all` is true. +- `SendC2CMessage`: sends a QQ single-chat message to `openid`. +- `SendGroupMessage`: sends a QQ group message to `group_openid`. +- `NormalizeEvent`: parses official QQ Bot payload JSON and returns a stable message shape. + +For text messages, leave `msg_type` as `0` and pass `content`. For markdown, ark, keyboard, media, or message reference fields, pass the corresponding `*_json` field as a JSON object string. + +## Receiving messages + +For production instances, set `autoStartGateway` to `true` so OctoBus starts the receive loop together with the long-running service process. You can also call `StartGateway` manually after instance start. + +Incoming C2C and group-at messages are stored in an in-memory queue: + +```json +{ + "max_messages": 10, + "ack": false +} +``` + +Call `PollMessages` to read messages, then call `AckMessage` with the returned `local_id` values after they are handled. The queue is process-local; restarting the service clears unacknowledged buffered messages. diff --git a/services/tencent__qq-chat/bin/tencent-qq-chat.js b/services/tencent__qq-chat/bin/tencent-qq-chat.js new file mode 100755 index 00000000..b87939f4 --- /dev/null +++ b/services/tencent__qq-chat/bin/tencent-qq-chat.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; +import { maybeAutoStartGatewayFromCli } from "../src/tencent-qq-chat.js"; + +await maybeAutoStartGatewayFromCli(); +runServiceMain(service); diff --git a/services/tencent__qq-chat/config.schema.json b/services/tencent__qq-chat/config.schema.json new file mode 100644 index 00000000..32f00534 --- /dev/null +++ b/services/tencent__qq-chat/config.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "baseUrl": { + "type": "string", + "default": "https://api.sgroup.qq.com", + "description": "QQ Bot OpenAPI base URL." + }, + "base_url": { + "type": "string", + "description": "Alias for baseUrl." + }, + "tokenUrl": { + "type": "string", + "default": "https://bots.qq.com/app/getAppAccessToken", + "description": "QQ Bot access-token endpoint." + }, + "token_url": { + "type": "string", + "description": "Alias for tokenUrl." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "description": "Alias for timeoutMs." + }, + "autoStartGateway": { + "type": "boolean", + "default": false, + "description": "Start the QQ Bot WebSocket Gateway receiver when the long-running service process starts." + }, + "auto_start_gateway": { + "type": "boolean", + "description": "Alias for autoStartGateway." + }, + "gatewayIntents": { + "type": "integer", + "minimum": 1, + "default": 33554432, + "description": "Gateway intents bitmask. Default is GROUP_AND_C2C_EVENT (1 << 25)." + }, + "gateway_intents": { + "type": "integer", + "minimum": 1, + "description": "Alias for gatewayIntents." + }, + "gatewayShardIndex": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Gateway shard index." + }, + "gateway_shard_index": { + "type": "integer", + "minimum": 0, + "description": "Alias for gatewayShardIndex." + }, + "gatewayShardCount": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Gateway shard count." + }, + "gateway_shard_count": { + "type": "integer", + "minimum": 1, + "description": "Alias for gatewayShardCount." + }, + "maxBufferedMessages": { + "type": "integer", + "minimum": 1, + "default": 1000, + "description": "Maximum number of received Gateway message events kept in memory." + }, + "max_buffered_messages": { + "type": "integer", + "minimum": 1, + "description": "Alias for maxBufferedMessages." + }, + "gatewayReconnectMs": { + "type": "integer", + "minimum": 100, + "default": 5000, + "description": "Reconnect delay after Gateway disconnects." + }, + "gateway_reconnect_ms": { + "type": "integer", + "minimum": 100, + "description": "Alias for gatewayReconnectMs." + } + } +} diff --git a/services/tencent__qq-chat/package.json b/services/tencent__qq-chat/package.json new file mode 100644 index 00000000..62317406 --- /dev/null +++ b/services/tencent__qq-chat/package.json @@ -0,0 +1,12 @@ +{ + "name": "tencent-qq-chat", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "tencent-qq-chat": "bin/tencent-qq-chat.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/tencent__qq-chat/proto/tencent_qq_chat.proto b/services/tencent__qq-chat/proto/tencent_qq_chat.proto new file mode 100644 index 00000000..0f38ab8a --- /dev/null +++ b/services/tencent__qq-chat/proto/tencent_qq_chat.proto @@ -0,0 +1,144 @@ +syntax = "proto3"; + +package Tencent_QQ_Chat; + +option go_package = "miner/grpc-service/Tencent_QQ_Chat"; + +// Tencent_QQ_Chat wraps QQ Bot OpenAPI v2 chat messaging. +service Tencent_QQ_Chat { + rpc GetAccessToken(GetAccessTokenRequest) returns (GetAccessTokenResponse) {} + rpc StartGateway(StartGatewayRequest) returns (GatewayStatus) {} + rpc StopGateway(StopGatewayRequest) returns (GatewayStatus) {} + rpc GetGatewayStatus(GetGatewayStatusRequest) returns (GatewayStatus) {} + rpc PollMessages(PollMessagesRequest) returns (PollMessagesResponse) {} + rpc AckMessage(AckMessageRequest) returns (AckMessageResponse) {} + rpc SendC2CMessage(SendC2CMessageRequest) returns (SendMessageResponse) {} + rpc SendGroupMessage(SendGroupMessageRequest) returns (SendMessageResponse) {} + rpc NormalizeEvent(NormalizeEventRequest) returns (NormalizeEventResponse) {} +} + +message GetAccessTokenRequest { + bool force_refresh = 1; +} + +message GetAccessTokenResponse { + bool success = 1; + int32 http_status = 2; + string access_token = 3; + int32 expires_in = 4; + string http_body = 5; +} + +message StartGatewayRequest { + bool force_restart = 1; +} + +message StopGatewayRequest {} + +message GetGatewayStatusRequest {} + +message GatewayStatus { + bool running = 1; + bool ready = 2; + string state = 3; + string session_id = 4; + string gateway_url = 5; + string last_event_type = 6; + string last_error = 7; + string started_at = 8; + string last_event_at = 9; + string last_heartbeat_ack_at = 10; + int32 queue_size = 11; + int32 dropped_messages = 12; + int32 reconnect_count = 13; + int32 intents = 14; +} + +message PollMessagesRequest { + int32 max_messages = 1; + bool ack = 2; +} + +message ReceivedMessage { + string local_id = 1; + string received_at = 2; + NormalizeEventResponse event = 3; +} + +message PollMessagesResponse { + repeated ReceivedMessage messages = 1; + int32 queue_size = 2; +} + +message AckMessageRequest { + repeated string local_id = 1; + bool all = 2; +} + +message AckMessageResponse { + int32 acked = 1; + int32 queue_size = 2; +} + +message SendC2CMessageRequest { + string openid = 1; + string content = 2; + int32 msg_type = 3; + string markdown_json = 4; + string keyboard_json = 5; + string ark_json = 6; + string media_json = 7; + string message_reference_json = 8; + string event_id = 9; + string msg_id = 10; + int32 msg_seq = 11; + bool is_wakeup = 12; +} + +message SendGroupMessageRequest { + string group_openid = 1; + string content = 2; + int32 msg_type = 3; + string markdown_json = 4; + string keyboard_json = 5; + string ark_json = 6; + string media_json = 7; + string message_reference_json = 8; + string event_id = 9; + string msg_id = 10; + int32 msg_seq = 11; +} + +message SendMessageResponse { + bool success = 1; + int32 http_status = 2; + string id = 3; + string timestamp = 4; + string http_body = 5; +} + +message NormalizeEventRequest { + string payload_json = 1; +} + +message NormalizeEventResponse { + bool is_message = 1; + bool is_c2c = 2; + bool is_group = 3; + bool is_channel = 4; + string event_id = 5; + int32 op = 6; + string event_type = 7; + string seq = 8; + string message_id = 9; + string content = 10; + string timestamp = 11; + string openid = 12; + string group_openid = 13; + string channel_id = 14; + string guild_id = 15; + string author_json = 16; + string attachments_json = 17; + string data_json = 18; + string payload_json = 19; +} diff --git a/services/tencent__qq-chat/secret.schema.json b/services/tencent__qq-chat/secret.schema.json new file mode 100644 index 00000000..0aa440ff --- /dev/null +++ b/services/tencent__qq-chat/secret.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "appId": { + "type": "string", + "description": "QQ Bot AppID." + }, + "app_id": { + "type": "string", + "description": "Alias for appId." + }, + "appSecret": { + "type": "string", + "description": "QQ Bot AppSecret." + }, + "app_secret": { + "type": "string", + "description": "Alias for appSecret." + }, + "clientSecret": { + "type": "string", + "description": "Alias for appSecret." + }, + "client_secret": { + "type": "string", + "description": "Alias for appSecret." + }, + "accessToken": { + "type": "string", + "description": "Pre-fetched AccessToken. If omitted, appId and appSecret are used to fetch one." + }, + "access_token": { + "type": "string", + "description": "Alias for accessToken." + } + } +} diff --git a/services/tencent__qq-chat/service.json b/services/tencent__qq-chat/service.json new file mode 100644 index 00000000..84d6806c --- /dev/null +++ b/services/tencent__qq-chat/service.json @@ -0,0 +1,61 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "tencent-qq-chat", + "displayName": "Tencent QQ Chat", + "description": "QQ Bot OpenAPI v2 service for sending C2C/group messages and normalizing incoming chat events.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/tencent_qq_chat.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Tencent_QQ_Chat.Tencent_QQ_Chat/GetAccessToken": { + "name": "get-access-token", + "description": "Fetch a QQ Bot OpenAPI access token." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/StartGateway": { + "name": "start-gateway", + "description": "Start the QQ Bot WebSocket Gateway receive loop." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/StopGateway": { + "name": "stop-gateway", + "description": "Stop the QQ Bot WebSocket Gateway receive loop." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/GetGatewayStatus": { + "name": "get-gateway-status", + "description": "Return Gateway receiver status and buffered message counts." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/PollMessages": { + "name": "poll-messages", + "description": "Poll received QQ message events buffered by the Gateway receiver." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/AckMessage": { + "name": "ack-message", + "description": "Acknowledge and remove buffered received messages." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/SendC2CMessage": { + "name": "send-c2c-message", + "description": "Send a QQ C2C message through QQ Bot OpenAPI." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/SendGroupMessage": { + "name": "send-group-message", + "description": "Send a QQ group message through QQ Bot OpenAPI." + }, + "Tencent_QQ_Chat.Tencent_QQ_Chat/NormalizeEvent": { + "name": "normalize-event", + "description": "Normalize an incoming QQ Bot event payload." + } + } + } + } +} diff --git a/services/tencent__qq-chat/src/service.js b/services/tencent__qq-chat/src/service.js new file mode 100644 index 00000000..0ae79327 --- /dev/null +++ b/services/tencent__qq-chat/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./tencent-qq-chat.js"; + +export { handlers } from "./tencent-qq-chat.js"; + +export const service = defineService({ handlers }); diff --git a/services/tencent__qq-chat/src/tencent-qq-chat.js b/services/tencent__qq-chat/src/tencent-qq-chat.js new file mode 100644 index 00000000..a3e8aacc --- /dev/null +++ b/services/tencent__qq-chat/src/tencent-qq-chat.js @@ -0,0 +1,893 @@ +import fs from "node:fs"; +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +export const METHOD_GET_ACCESS_TOKEN_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/GetAccessToken"; +export const METHOD_START_GATEWAY_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/StartGateway"; +export const METHOD_STOP_GATEWAY_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/StopGateway"; +export const METHOD_GET_GATEWAY_STATUS_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/GetGatewayStatus"; +export const METHOD_POLL_MESSAGES_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/PollMessages"; +export const METHOD_ACK_MESSAGE_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/AckMessage"; +export const METHOD_SEND_C2C_MESSAGE_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/SendC2CMessage"; +export const METHOD_SEND_GROUP_MESSAGE_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/SendGroupMessage"; +export const METHOD_NORMALIZE_EVENT_PATH = "/Tencent_QQ_Chat.Tencent_QQ_Chat/NormalizeEvent"; + +export const METHOD_GET_ACCESS_TOKEN_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/GetAccessToken"; +export const METHOD_START_GATEWAY_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/StartGateway"; +export const METHOD_STOP_GATEWAY_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/StopGateway"; +export const METHOD_GET_GATEWAY_STATUS_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/GetGatewayStatus"; +export const METHOD_POLL_MESSAGES_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/PollMessages"; +export const METHOD_ACK_MESSAGE_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/AckMessage"; +export const METHOD_SEND_C2C_MESSAGE_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/SendC2CMessage"; +export const METHOD_SEND_GROUP_MESSAGE_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/SendGroupMessage"; +export const METHOD_NORMALIZE_EVENT_FULL = "Tencent_QQ_Chat.Tencent_QQ_Chat/NormalizeEvent"; + +export const DEFAULT_API_BASE_URL = "https://api.sgroup.qq.com"; +export const DEFAULT_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; +export const DEFAULT_TIMEOUT_MS = 5000; +export const DEFAULT_GATEWAY_INTENTS = 1 << 25; +export const DEFAULT_GATEWAY_RECONNECT_MS = 5000; +export const DEFAULT_MAX_BUFFERED_MESSAGES = 1000; + +const tokenCache = new Map(); + +const gatewayState = { + running: false, + ready: false, + state: "stopped", + sessionId: "", + gatewayUrl: "", + lastEventType: "", + lastError: "", + startedAt: "", + lastEventAt: "", + lastHeartbeatAckAt: "", + queue: [], + droppedMessages: 0, + reconnectCount: 0, + intents: DEFAULT_GATEWAY_INTENTS, + seq: null, + ws: null, + heartbeatTimer: null, + reconnectTimer: null, + ctx: null, + localMessageSeq: 0, + connectionId: 0, +}; + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), message); + err.legacyCode = code; + return err; +}; + +const upstreamError = (code, message, details = {}) => { + const err = errorWithCode(code, message); + err.httpStatus = Number.isFinite(Number(details.httpStatus)) ? Number(details.httpStatus) : 0; + err.httpBody = typeof details.httpBody === "string" ? details.httpBody : ""; + err.responseCode = Number.isFinite(Number(details.responseCode)) ? Number(details.responseCode) : undefined; + err.responseMessage = typeof details.responseMessage === "string" ? details.responseMessage : ""; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === "object" && value !== null && hasOwn(value, "value")) return unwrapScalar(value.value); + return value; +}; + +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null); + +const toTrimmedString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ""; + return String(raw).trim(); +}; + +const toBoolean = (value) => { + const raw = unwrapScalar(value); + if (typeof raw === "boolean") return raw; + if (typeof raw === "number") return Number.isFinite(raw) && raw !== 0; + if (typeof raw === "string") { + const text = raw.trim().toLowerCase(); + if (["1", "true", "yes", "y", "on"].includes(text)) return true; + if (["0", "false", "no", "n", "off", ""].includes(text)) return false; + } + return false; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +const optionalUint32 = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === "") return undefined; + const num = Number(raw); + if (!Number.isFinite(num) || num <= 0) return undefined; + return Math.trunc(num); +}; + +const resolveTimeoutMs = (ctx = {}) => { + const bindings = ctx.bindings || mergedBindings(ctx); + return optionalUint32(ctx.limits?.timeoutMs) ?? optionalUint32(bindings.timeoutMs) ?? optionalUint32(bindings.timeout_ms) ?? DEFAULT_TIMEOUT_MS; +}; + +const createTimeoutSignal = (timeoutMs) => { + if (typeof globalThis.AbortSignal?.timeout === "function") return globalThis.AbortSignal.timeout(timeoutMs); + const controller = new globalThis.AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref?.(); + return controller.signal; +}; + +const fetchWithTimeout = (url, init = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => fetch(url, { + ...init, + signal: init.signal ?? createTimeoutSignal(timeoutMs), +}); + +const optionalIntWithDefault = (value, fallback, minimum = 0) => { + const parsed = optionalUint32(value); + if (parsed === undefined || parsed < minimum) return fallback; + return parsed; +}; + +const resolveGatewayIntents = (bindings = {}) => optionalIntWithDefault(firstDefined(bindings.gatewayIntents, bindings.gateway_intents), DEFAULT_GATEWAY_INTENTS, 1); +const resolveGatewayShardIndex = (bindings = {}) => optionalIntWithDefault(firstDefined(bindings.gatewayShardIndex, bindings.gateway_shard_index), 0, 0); +const resolveGatewayShardCount = (bindings = {}) => optionalIntWithDefault(firstDefined(bindings.gatewayShardCount, bindings.gateway_shard_count), 1, 1); +const resolveMaxBufferedMessages = (bindings = {}) => optionalIntWithDefault(firstDefined(bindings.maxBufferedMessages, bindings.max_buffered_messages), DEFAULT_MAX_BUFFERED_MESSAGES, 1); +const resolveGatewayReconnectMs = (bindings = {}) => optionalIntWithDefault(firstDefined(bindings.gatewayReconnectMs, bindings.gateway_reconnect_ms), DEFAULT_GATEWAY_RECONNECT_MS, 100); +const resolveAutoStartGateway = (bindings = {}) => toBoolean(firstDefined(bindings.autoStartGateway, bindings.auto_start_gateway)); + +const requireString = (value, fieldName) => { + const text = toTrimmedString(value); + if (!text) throw errorWithCode("INVALID_ARGUMENT", `${fieldName} is required`); + return text; +}; + +const optionalInt32 = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === "") return undefined; + const num = Number(raw); + if (!Number.isFinite(num)) return undefined; + return Math.trunc(num); +}; + +const normalizeBaseUrl = (value, fallback, fieldName) => { + const raw = toTrimmedString(value) || fallback; + let parsed; + try { + parsed = new URL(raw); + } catch { + throw errorWithCode("INVALID_ARGUMENT", `${fieldName} must be a valid HTTP/HTTPS URL`); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw errorWithCode("INVALID_ARGUMENT", `${fieldName} must be a valid HTTP/HTTPS URL`); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +}; + +const resolveApiBaseUrl = (bindings = {}) => normalizeBaseUrl(firstDefined(bindings.baseUrl, bindings.base_url), DEFAULT_API_BASE_URL, "baseUrl"); +const resolveTokenUrl = (bindings = {}) => normalizeBaseUrl(firstDefined(bindings.tokenUrl, bindings.token_url), DEFAULT_TOKEN_URL, "tokenUrl"); +const resolveAppId = (bindings = {}) => toTrimmedString(firstDefined(bindings.appId, bindings.app_id)); +const resolveAppSecret = (bindings = {}) => toTrimmedString(firstDefined(bindings.appSecret, bindings.app_secret, bindings.clientSecret, bindings.client_secret)); +const resolveConfiguredAccessToken = (bindings = {}) => toTrimmedString(firstDefined(bindings.accessToken, bindings.access_token)); + +const parseJsonBody = (bodyText, label = "response") => { + const text = String(bodyText ?? ""); + if (!text.trim()) return {}; + try { + return JSON.parse(text); + } catch { + throw upstreamError("UNKNOWN", `qq bot ${label} body is not valid JSON`, { httpBody: text }); + } +}; + +const mapHttpStatusToCode = (status) => { + if (status === 401) return "UNAUTHENTICATED"; + if (status === 403) return "PERMISSION_DENIED"; + if (status === 429) return "UNAVAILABLE"; + if (status >= 400 && status < 500) return "FAILED_PRECONDITION"; + if (status >= 500) return "UNAVAILABLE"; + return "UNKNOWN"; +}; + +const mapErrorBodyToCode = (body = {}) => { + const code = Number(body.code ?? body.errcode); + if (code === 0 || !Number.isFinite(code)) return "UNKNOWN"; + if ([401, 11263].includes(code)) return "UNAUTHENTICATED"; + if ([403, 11264].includes(code)) return "PERMISSION_DENIED"; + if (code === 22009 || code === 429) return "UNAVAILABLE"; + return "FAILED_PRECONDITION"; +}; + +const tokenCacheKey = (tokenUrl, appId, appSecret) => `${tokenUrl}\n${appId}\n${appSecret}`; + +const readTokenCache = (key, nowMs = Date.now()) => { + const cached = tokenCache.get(key); + if (!cached || !cached.accessToken) return ""; + if (cached.expiresAtMs <= nowMs + 60_000) return ""; + return cached.accessToken; +}; + +const writeTokenCache = (key, accessToken, expiresIn, nowMs = Date.now()) => { + const ttlMs = Math.max(0, Number(expiresIn || 0) * 1000); + tokenCache.set(key, { + accessToken, + expiresAtMs: nowMs + ttlMs, + expiresIn: Number(expiresIn || 0), + }); +}; + +const fetchAppAccessToken = async (ctx = {}, forceRefresh = false) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings || {}; + const configured = resolveConfiguredAccessToken(bindings); + if (configured && !forceRefresh) { + return { + success: true, + http_status: 0, + access_token: configured, + expires_in: 0, + http_body: "", + }; + } + + const appId = requireString(resolveAppId(bindings), "appId"); + const appSecret = requireString(resolveAppSecret(bindings), "appSecret"); + const tokenUrl = resolveTokenUrl(bindings); + const cacheKey = tokenCacheKey(tokenUrl, appId, appSecret); + const cachedToken = forceRefresh ? "" : readTokenCache(cacheKey); + if (cachedToken) { + return { + success: true, + http_status: 0, + access_token: cachedToken, + expires_in: 0, + http_body: "", + }; + } + + let response; + try { + response = await fetchWithTimeout(tokenUrl, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, */*;q=0.8", + }, + body: JSON.stringify({ appId, clientSecret: appSecret }), + }, resolveTimeoutMs(callCtx)); + } catch (err) { + throw upstreamError("UNAVAILABLE", "qq bot access token request failed", { + httpStatus: 0, + responseMessage: err?.cause?.message || err?.message || "fetch failed", + }); + } + + const httpStatus = Number(response.status || 0); + const httpBody = String((await response.text()) ?? ""); + const body = parseJsonBody(httpBody, "access token response"); + if (httpStatus < 200 || httpStatus >= 300) { + throw upstreamError(mapHttpStatusToCode(httpStatus), `qq bot token http ${httpStatus}`, { + httpStatus, + httpBody, + responseCode: body.code ?? body.errcode, + responseMessage: body.message ?? body.errmsg, + }); + } + + const accessToken = requireString(body.access_token, "access_token"); + const expiresIn = optionalInt32(body.expires_in) ?? 0; + writeTokenCache(cacheKey, accessToken, expiresIn); + return { + success: true, + http_status: httpStatus, + access_token: accessToken, + expires_in: expiresIn, + http_body: httpBody, + }; +}; + +const resolveAccessToken = async (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const configured = resolveConfiguredAccessToken(callCtx.bindings || {}); + if (configured) return configured; + return (await fetchAppAccessToken(callCtx, false)).access_token; +}; + +const gatewayStatus = () => ({ + running: gatewayState.running, + ready: gatewayState.ready, + state: gatewayState.state, + session_id: gatewayState.sessionId, + gateway_url: gatewayState.gatewayUrl, + last_event_type: gatewayState.lastEventType, + last_error: gatewayState.lastError, + started_at: gatewayState.startedAt, + last_event_at: gatewayState.lastEventAt, + last_heartbeat_ack_at: gatewayState.lastHeartbeatAckAt, + queue_size: gatewayState.queue.length, + dropped_messages: gatewayState.droppedMessages, + reconnect_count: gatewayState.reconnectCount, + intents: gatewayState.intents, +}); + +const clearGatewayTimers = () => { + if (gatewayState.heartbeatTimer) clearInterval(gatewayState.heartbeatTimer); + if (gatewayState.reconnectTimer) clearTimeout(gatewayState.reconnectTimer); + gatewayState.heartbeatTimer = null; + gatewayState.reconnectTimer = null; +}; + +const closeGatewaySocket = () => { + const ws = gatewayState.ws; + gatewayState.ws = null; + clearGatewayTimers(); + if (!ws) return; + try { + ws.__octobusIntentionalClose = true; + ws.close(); + } catch { + // Ignore best-effort close errors. + } +}; + +const gatewayReceiveTargets = new Set([ + "C2C_MESSAGE_CREATE", + "GROUP_AT_MESSAGE_CREATE", + "GROUP_MESSAGE_CREATE", + "DIRECT_MESSAGE_CREATE", + "AT_MESSAGE_CREATE", + "MESSAGE_CREATE", +]); + +const enqueueGatewayMessage = async (payload) => { + const event = await handleNormalizeEvent({ payload_json: JSON.stringify(payload) }); + if (!event.is_message) return; + const maxBuffered = resolveMaxBufferedMessages(gatewayState.ctx?.bindings || {}); + gatewayState.localMessageSeq += 1; + gatewayState.queue.push({ + local_id: String(gatewayState.localMessageSeq), + received_at: new Date().toISOString(), + event, + }); + while (gatewayState.queue.length > maxBuffered) { + gatewayState.queue.shift(); + gatewayState.droppedMessages += 1; + } +}; + +const fetchGatewayInfo = async (ctx = {}, accessToken) => { + const baseUrl = resolveApiBaseUrl(resolveCallContext(ctx).bindings || {}); + const response = await fetchWithTimeout(buildApiUrl(baseUrl, "gateway/bot"), { + method: "GET", + headers: { + authorization: `QQBot ${accessToken}`, + accept: "application/json, */*;q=0.8", + }, + }, resolveTimeoutMs(ctx)); + const httpStatus = Number(response.status || 0); + const httpBody = String((await response.text()) ?? ""); + const body = parseJsonBody(httpBody, "gateway response"); + if (httpStatus < 200 || httpStatus >= 300 || !body.url) { + throw upstreamError(mapHttpStatusToCode(httpStatus), `qq bot gateway http ${httpStatus}`, { + httpStatus, + httpBody, + responseCode: body.code ?? body.errcode, + responseMessage: body.message ?? body.errmsg, + }); + } + return body; +}; + +const scheduleGatewayReconnect = () => { + if (!gatewayState.running || gatewayState.reconnectTimer) return; + const delayMs = resolveGatewayReconnectMs(gatewayState.ctx?.bindings || {}); + gatewayState.state = "reconnecting"; + gatewayState.reconnectTimer = setTimeout(() => { + gatewayState.reconnectTimer = null; + gatewayState.reconnectCount += 1; + connectGateway().catch((err) => { + gatewayState.lastError = err?.message || String(err); + scheduleGatewayReconnect(); + }); + }, delayMs); +}; + +const identifyGateway = (accessToken) => { + const bindings = gatewayState.ctx?.bindings || {}; + const shardIndex = resolveGatewayShardIndex(bindings); + const shardCount = resolveGatewayShardCount(bindings); + gatewayState.ws?.send(JSON.stringify({ + op: 2, + d: { + token: `QQBot ${accessToken}`, + intents: gatewayState.intents, + shard: [shardIndex, shardCount], + properties: {}, + }, + })); +}; + +const sendHeartbeat = () => { + if (!gatewayState.ws || gatewayState.ws.readyState !== WebSocket.OPEN) return; + gatewayState.ws.send(JSON.stringify({ op: 1, d: gatewayState.seq })); +}; + +const handleGatewayPayload = async (payload, accessToken) => { + if (payload.s !== undefined && payload.s !== null) gatewayState.seq = payload.s; + if (payload.t) gatewayState.lastEventType = String(payload.t); + + if (payload.op === 10) { + const interval = Number(payload.d?.heartbeat_interval || 45_000); + if (gatewayState.heartbeatTimer) clearInterval(gatewayState.heartbeatTimer); + gatewayState.heartbeatTimer = setInterval(sendHeartbeat, interval); + identifyGateway(accessToken); + return; + } + if (payload.op === 11) { + gatewayState.lastHeartbeatAckAt = new Date().toISOString(); + return; + } + if (payload.op === 7 || payload.op === 9) { + gatewayState.lastError = payload.op === 7 ? "gateway requested reconnect" : "gateway invalid session"; + closeGatewaySocket(); + scheduleGatewayReconnect(); + return; + } + if (payload.op !== 0) return; + + gatewayState.lastEventAt = new Date().toISOString(); + if (payload.t === "READY") { + gatewayState.ready = true; + gatewayState.state = "ready"; + gatewayState.sessionId = toTrimmedString(payload.d?.session_id); + return; + } + if (gatewayReceiveTargets.has(String(payload.t))) { + await enqueueGatewayMessage(payload); + } +}; + +const connectGateway = async () => { + if (!gatewayState.running || !gatewayState.ctx) return; + const connectionId = gatewayState.connectionId + 1; + gatewayState.connectionId = connectionId; + gatewayState.state = "connecting"; + gatewayState.ready = false; + gatewayState.lastError = ""; + clearGatewayTimers(); + closeGatewaySocket(); + + const accessToken = await resolveAccessToken(gatewayState.ctx); + const gatewayInfo = await fetchGatewayInfo(gatewayState.ctx, accessToken); + gatewayState.gatewayUrl = toTrimmedString(gatewayInfo.url); + + const WebSocketCtor = globalThis.WebSocket; + if (typeof WebSocketCtor !== "function") { + throw errorWithCode("FAILED_PRECONDITION", "global WebSocket is not available in this Node.js runtime"); + } + + const ws = new WebSocketCtor(gatewayState.gatewayUrl); + gatewayState.ws = ws; + + ws.addEventListener("open", () => { + if (gatewayState.ws !== ws || gatewayState.connectionId !== connectionId) return; + gatewayState.state = "open"; + }); + ws.addEventListener("message", (event) => { + Promise.resolve() + .then(async () => { + if (gatewayState.ws !== ws || gatewayState.connectionId !== connectionId) return; + const payload = JSON.parse(String(event.data)); + await handleGatewayPayload(payload, accessToken); + }) + .catch((err) => { + gatewayState.lastError = err?.message || String(err); + }); + }); + ws.addEventListener("error", (event) => { + if (gatewayState.ws !== ws || gatewayState.connectionId !== connectionId) return; + gatewayState.lastError = event?.message || "gateway websocket error"; + }); + ws.addEventListener("close", () => { + const intentional = ws.__octobusIntentionalClose === true; + if (gatewayState.ws !== ws || gatewayState.connectionId !== connectionId) return; + gatewayState.ws = null; + gatewayState.ready = false; + clearGatewayTimers(); + if (intentional) { + if (!gatewayState.running) gatewayState.state = "stopped"; + return; + } + if (gatewayState.running) scheduleGatewayReconnect(); + else gatewayState.state = "stopped"; + }); +}; + +const startGateway = async (ctx = {}, forceRestart = false) => { + const callCtx = resolveCallContext(ctx); + if (gatewayState.running && !forceRestart) return gatewayStatus(); + await stopGateway(); + gatewayState.running = true; + gatewayState.ready = false; + gatewayState.state = "starting"; + gatewayState.startedAt = new Date().toISOString(); + gatewayState.ctx = callCtx; + gatewayState.intents = resolveGatewayIntents(callCtx.bindings || {}); + gatewayState.seq = null; + await connectGateway(); + return gatewayStatus(); +}; + +const stopGateway = async () => { + gatewayState.running = false; + gatewayState.ready = false; + gatewayState.state = "stopped"; + clearGatewayTimers(); + closeGatewaySocket(); + return gatewayStatus(); +}; + +const pollMessages = (req = {}) => { + const maxMessages = optionalIntWithDefault(firstDefined(req.max_messages, req.maxMessages), 10, 1); + const messages = gatewayState.queue.slice(0, maxMessages); + if (toBoolean(req.ack)) { + gatewayState.queue.splice(0, messages.length); + } + return { + messages, + queue_size: gatewayState.queue.length, + }; +}; + +const ackMessage = (req = {}) => { + if (toBoolean(req.all)) { + const acked = gatewayState.queue.length; + gatewayState.queue = []; + return { acked, queue_size: 0 }; + } + const rawIds = firstDefined(req.local_id, req.localId); + const ids = Array.isArray(rawIds) ? rawIds.map(toTrimmedString).filter(Boolean) : []; + const idSet = new Set(ids); + const before = gatewayState.queue.length; + gatewayState.queue = gatewayState.queue.filter((message) => !idSet.has(message.local_id)); + return { + acked: before - gatewayState.queue.length, + queue_size: gatewayState.queue.length, + }; +}; + +const buildJsonObject = (jsonText, fieldName) => { + const text = toTrimmedString(jsonText); + if (!text) return undefined; + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${fieldName} must be a JSON object`); + } + return parsed; + } catch (err) { + throw errorWithCode("INVALID_ARGUMENT", err?.message || `${fieldName} must be valid JSON`); + } +}; + +const addJsonObject = (payload, fieldName, jsonText) => { + const parsed = buildJsonObject(jsonText, `${fieldName}_json`); + if (parsed !== undefined) payload[fieldName] = parsed; +}; + +const buildMessagePayload = (req = {}, options = {}) => { + const msgType = optionalInt32(firstDefined(req.msg_type, req.msgType)) ?? 0; + const payload = { msg_type: msgType }; + const content = toTrimmedString(req.content); + if (content) payload.content = content; + if (msgType === 0 && !content) throw errorWithCode("INVALID_ARGUMENT", "content is required for text messages"); + + addJsonObject(payload, "markdown", firstDefined(req.markdown_json, req.markdownJson)); + addJsonObject(payload, "keyboard", firstDefined(req.keyboard_json, req.keyboardJson)); + addJsonObject(payload, "ark", firstDefined(req.ark_json, req.arkJson)); + addJsonObject(payload, "media", firstDefined(req.media_json, req.mediaJson)); + addJsonObject(payload, "message_reference", firstDefined(req.message_reference_json, req.messageReferenceJson)); + + const eventId = toTrimmedString(firstDefined(req.event_id, req.eventId)); + const msgId = toTrimmedString(firstDefined(req.msg_id, req.msgId)); + const msgSeq = optionalInt32(firstDefined(req.msg_seq, req.msgSeq)); + if (eventId) payload.event_id = eventId; + if (msgId) payload.msg_id = msgId; + if (msgSeq !== undefined) payload.msg_seq = msgSeq; + if (options.allowWakeup && toBoolean(firstDefined(req.is_wakeup, req.isWakeup))) payload.is_wakeup = true; + return payload; +}; + +const buildApiUrl = (baseUrl, path) => new URL(path, `${baseUrl}/`).toString(); + +const callOpenApi = async (ctx = {}, path, payload = {}) => { + const callCtx = resolveCallContext(ctx); + const baseUrl = resolveApiBaseUrl(callCtx.bindings || {}); + const accessToken = await resolveAccessToken(callCtx); + const url = buildApiUrl(baseUrl, path.replace(/^\/+/, "")); + + let response; + try { + response = await fetchWithTimeout(url, { + method: "POST", + headers: { + authorization: `QQBot ${accessToken}`, + "content-type": "application/json", + accept: "application/json, */*;q=0.8", + }, + body: JSON.stringify(payload), + }, resolveTimeoutMs(callCtx)); + } catch (err) { + throw upstreamError("UNAVAILABLE", "qq bot openapi request failed", { + httpStatus: 0, + responseMessage: err?.cause?.message || err?.message || "fetch failed", + }); + } + + const httpStatus = Number(response.status || 0); + const httpBody = String((await response.text()) ?? ""); + const body = parseJsonBody(httpBody, "openapi response"); + if (httpStatus < 200 || httpStatus >= 300) { + throw upstreamError(mapHttpStatusToCode(httpStatus), `qq bot openapi http ${httpStatus}`, { + httpStatus, + httpBody, + responseCode: body.code ?? body.errcode, + responseMessage: body.message ?? body.errmsg, + }); + } + if (body.code !== undefined && Number(body.code) !== 0) { + throw upstreamError(mapErrorBodyToCode(body), "qq bot openapi business failure", { + httpStatus, + httpBody, + responseCode: body.code, + responseMessage: body.message, + }); + } + return { httpStatus, httpBody, body }; +}; + +const buildSendMessageResponse = ({ httpStatus, httpBody, body }) => ({ + success: true, + http_status: httpStatus, + id: toTrimmedString(body.id), + timestamp: toTrimmedString(body.timestamp), + http_body: httpBody, +}); + +const handleGetAccessToken = async (req = {}, ctx = {}) => fetchAppAccessToken(ctx, toBoolean(firstDefined(req.force_refresh, req.forceRefresh))); + +const handleStartGateway = async (req = {}, ctx = {}) => startGateway(ctx, toBoolean(firstDefined(req.force_restart, req.forceRestart))); +const handleStopGateway = async () => stopGateway(); +const handleGetGatewayStatus = async () => gatewayStatus(); +const handlePollMessages = async (req = {}) => pollMessages(req); +const handleAckMessage = async (req = {}) => ackMessage(req); + +const handleSendC2CMessage = async (req = {}, ctx = {}) => { + const openid = requireString(firstDefined(req.openid, req.openId), "openid"); + const payload = buildMessagePayload(req, { allowWakeup: true }); + return buildSendMessageResponse(await callOpenApi(ctx, `/v2/users/${encodeURIComponent(openid)}/messages`, payload)); +}; + +const handleSendGroupMessage = async (req = {}, ctx = {}) => { + const groupOpenid = requireString(firstDefined(req.group_openid, req.groupOpenid), "group_openid"); + const payload = buildMessagePayload(req, { allowWakeup: false }); + return buildSendMessageResponse(await callOpenApi(ctx, `/v2/groups/${encodeURIComponent(groupOpenid)}/messages`, payload)); +}; + +const parsePayload = (req = {}) => { + const candidate = firstDefined(req.payload_json, req.payloadJson, req.payload); + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) return candidate; + const text = toTrimmedString(candidate); + if (!text) throw errorWithCode("INVALID_ARGUMENT", "payload_json is required"); + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("payload_json must be a JSON object"); + } + return parsed; + } catch (err) { + throw errorWithCode("INVALID_ARGUMENT", err?.message || "payload_json must be valid JSON"); + } +}; + +const stringifyId = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === "") return ""; + if (typeof raw === "number" && Number.isFinite(raw)) return String(Math.trunc(raw)); + return String(raw); +}; + +const eventData = (payload = {}) => { + if (payload.d && typeof payload.d === "object" && !Array.isArray(payload.d)) return payload.d; + return payload; +}; + +const handleNormalizeEvent = async (req = {}) => { + const payload = parsePayload(req); + const data = eventData(payload); + const author = data.author && typeof data.author === "object" ? data.author : {}; + const eventType = stringifyId(payload.t ?? data.t); + const openid = stringifyId(author.user_openid ?? author.member_openid ?? author.id ?? data.openid); + const groupOpenid = stringifyId(data.group_openid); + const channelId = stringifyId(data.channel_id); + const guildId = stringifyId(data.guild_id); + const messageTypes = new Set(["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", "GROUP_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE", "AT_MESSAGE_CREATE", "MESSAGE_CREATE"]); + + return { + is_message: messageTypes.has(eventType) || Boolean(data.content && (openid || groupOpenid || channelId)), + is_c2c: eventType === "C2C_MESSAGE_CREATE" || Boolean(openid && !groupOpenid && !channelId), + is_group: eventType === "GROUP_AT_MESSAGE_CREATE" || eventType === "GROUP_MESSAGE_CREATE" || Boolean(groupOpenid), + is_channel: eventType === "DIRECT_MESSAGE_CREATE" || eventType === "AT_MESSAGE_CREATE" || eventType === "MESSAGE_CREATE" || Boolean(channelId || guildId), + event_id: stringifyId(payload.id), + op: optionalInt32(payload.op) ?? 0, + event_type: eventType, + seq: stringifyId(payload.s ?? data.seq), + message_id: stringifyId(data.id), + content: stringifyId(data.content), + timestamp: stringifyId(data.timestamp), + openid, + group_openid: groupOpenid, + channel_id: channelId, + guild_id: guildId, + author_json: JSON.stringify(author), + attachments_json: JSON.stringify(Array.isArray(data.attachments) ? data.attachments : []), + data_json: JSON.stringify(data), + payload_json: JSON.stringify(payload), + }; +}; + +const registerHandlers = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + return { + [METHOD_GET_ACCESS_TOKEN_PATH]: (req = callCtx.req) => handleGetAccessToken(req ?? {}, callCtx), + [METHOD_START_GATEWAY_PATH]: (req = callCtx.req) => handleStartGateway(req ?? {}, callCtx), + [METHOD_STOP_GATEWAY_PATH]: () => handleStopGateway(), + [METHOD_GET_GATEWAY_STATUS_PATH]: () => handleGetGatewayStatus(), + [METHOD_POLL_MESSAGES_PATH]: (req = callCtx.req) => handlePollMessages(req ?? {}), + [METHOD_ACK_MESSAGE_PATH]: (req = callCtx.req) => handleAckMessage(req ?? {}), + [METHOD_SEND_C2C_MESSAGE_PATH]: (req = callCtx.req) => handleSendC2CMessage(req ?? {}, callCtx), + [METHOD_SEND_GROUP_MESSAGE_PATH]: (req = callCtx.req) => handleSendGroupMessage(req ?? {}, callCtx), + [METHOD_NORMALIZE_EVENT_PATH]: (req = callCtx.req) => handleNormalizeEvent(req ?? {}, callCtx), + }; +}; + +export function rpcdef(ctx = {}) { + return registerHandlers(ctx); +} + +const callSdkHandler = (ctx, path) => registerHandlers(ctx)[path](ctx?.request ?? ctx?.req ?? {}); + +export const handlers = { + [METHOD_GET_ACCESS_TOKEN_FULL]: (ctx) => callSdkHandler(ctx, METHOD_GET_ACCESS_TOKEN_PATH), + [METHOD_START_GATEWAY_FULL]: (ctx) => callSdkHandler(ctx, METHOD_START_GATEWAY_PATH), + [METHOD_STOP_GATEWAY_FULL]: (ctx) => callSdkHandler(ctx, METHOD_STOP_GATEWAY_PATH), + [METHOD_GET_GATEWAY_STATUS_FULL]: (ctx) => callSdkHandler(ctx, METHOD_GET_GATEWAY_STATUS_PATH), + [METHOD_POLL_MESSAGES_FULL]: (ctx) => callSdkHandler(ctx, METHOD_POLL_MESSAGES_PATH), + [METHOD_ACK_MESSAGE_FULL]: (ctx) => callSdkHandler(ctx, METHOD_ACK_MESSAGE_PATH), + [METHOD_SEND_C2C_MESSAGE_FULL]: (ctx) => callSdkHandler(ctx, METHOD_SEND_C2C_MESSAGE_PATH), + [METHOD_SEND_GROUP_MESSAGE_FULL]: (ctx) => callSdkHandler(ctx, METHOD_SEND_GROUP_MESSAGE_PATH), + [METHOD_NORMALIZE_EVENT_FULL]: (ctx) => callSdkHandler(ctx, METHOD_NORMALIZE_EVENT_PATH), +}; + +const parseRuntimeJsonArg = (argv, name) => { + const index = argv.indexOf(name); + if (index >= 0 && argv[index + 1]) return JSON.parse(argv[index + 1]); + const prefix = `${name}=`; + const inline = argv.find((arg) => arg.startsWith(prefix)); + if (inline) return JSON.parse(inline.slice(prefix.length)); + return undefined; +}; + +const parseRuntimeFileArg = (argv, name) => { + const index = argv.indexOf(name); + if (index >= 0 && argv[index + 1]) return JSON.parse(fs.readFileSync(argv[index + 1], "utf8")); + const prefix = `${name}=`; + const inline = argv.find((arg) => arg.startsWith(prefix)); + if (inline) return JSON.parse(fs.readFileSync(inline.slice(prefix.length), "utf8")); + return undefined; +}; + +const readRuntimeObject = (argv, fileArg, jsonArg) => parseRuntimeJsonArg(argv, jsonArg) ?? parseRuntimeFileArg(argv, fileArg) ?? {}; + +export async function maybeAutoStartGatewayFromCli(argv = process.argv.slice(2)) { + if (argv[0] !== "--runtime" || !["serve", "dev"].includes(argv[1])) return false; + const config = readRuntimeObject(argv, "--config", "--config-json"); + if (!resolveAutoStartGateway(config)) return false; + const secret = readRuntimeObject(argv, "--secret", "--secret-json"); + await startGateway({ config, secret }, true); + return true; +} + +export const _test = { + buildApiUrl, + buildJsonObject, + buildMessagePayload, + buildSendMessageResponse, + callOpenApi, + clearGatewayTimers, + closeGatewaySocket, + connectGateway, + errorWithCode, + enqueueGatewayMessage, + eventData, + fetchGatewayInfo, + fetchAppAccessToken, + firstDefined, + gatewayState, + gatewayStatus, + grpcCodeFor, + handleAckMessage, + handleGetAccessToken, + handleGetGatewayStatus, + handleNormalizeEvent, + handlePollMessages, + handleSendC2CMessage, + handleSendGroupMessage, + handleStartGateway, + handleStopGateway, + hasOwn, + maybeAutoStartGatewayFromCli, + mapErrorBodyToCode, + mapHttpStatusToCode, + mergedBindings, + normalizeBaseUrl, + optionalInt32, + optionalIntWithDefault, + optionalUint32, + parseJsonBody, + parsePayload, + pollMessages, + readTokenCache, + registerHandlers, + requireString, + resolveAccessToken, + resolveApiBaseUrl, + resolveAppId, + resolveAppSecret, + resolveCallContext, + resolveConfiguredAccessToken, + resolveAutoStartGateway, + resolveGatewayIntents, + resolveGatewayReconnectMs, + resolveGatewayShardCount, + resolveGatewayShardIndex, + resolveMaxBufferedMessages, + resolveTimeoutMs, + resolveTokenUrl, + startGateway, + stopGateway, + stringifyId, + toBoolean, + tokenCache, + tokenCacheKey, + toTrimmedString, + unwrapScalar, + upstreamError, + writeTokenCache, +}; diff --git a/services/tencent__qq-chat/test/tencent-qq-chat.test.js b/services/tencent__qq-chat/test/tencent-qq-chat.test.js new file mode 100644 index 00000000..76474220 --- /dev/null +++ b/services/tencent__qq-chat/test/tencent-qq-chat.test.js @@ -0,0 +1,477 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +import { + METHOD_ACK_MESSAGE_PATH, + METHOD_GET_GATEWAY_STATUS_PATH, + METHOD_GET_ACCESS_TOKEN_PATH, + METHOD_NORMALIZE_EVENT_FULL, + METHOD_NORMALIZE_EVENT_PATH, + METHOD_POLL_MESSAGES_PATH, + METHOD_SEND_C2C_MESSAGE_FULL, + METHOD_SEND_C2C_MESSAGE_PATH, + METHOD_SEND_GROUP_MESSAGE_PATH, + METHOD_START_GATEWAY_PATH, + METHOD_STOP_GATEWAY_PATH, + _test, + handlers, + rpcdef, +} from "../src/tencent-qq-chat.js"; +import { service } from "../src/service.js"; + +const originalFetch = globalThis.fetch; +const originalWebSocket = globalThis.WebSocket; + +const response = (status, body) => ({ + status, + text: async () => body, +}); + +const setFetch = (impl) => { + globalThis.fetch = impl; +}; + +const buildCtx = (overrides = {}) => ({ + config: { + baseUrl: "https://api.sgroup.qq.com", + tokenUrl: "https://bots.qq.com/app/getAppAccessToken", + ...(overrides.config || {}), + }, + secret: { + appId: "app-1", + appSecret: "secret-1", + ...(overrides.secret || {}), + }, + bindings: overrides.bindings || {}, + limits: { timeoutMs: 1000, ...(overrides.limits || {}) }, + req: overrides.req || {}, +}); + +const expectGrpcError = async (fn, legacyCode, code) => { + let caught; + try { + await fn(); + } catch (err) { + caught = err; + } + assert.ok(caught, "expected function to reject"); + assert.ok(caught instanceof GrpcError); + assert.equal(caught.legacyCode, legacyCode); + assert.equal(caught.code, code); + return caught; +}; + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + globalThis.WebSocket = originalWebSocket; + _test.tokenCache.clear(); + await _test.stopGateway(); + _test.gatewayState.queue = []; + _test.gatewayState.droppedMessages = 0; + _test.gatewayState.localMessageSeq = 0; +}); + +test("service exports defineService result and handlers", () => { + assert.equal(typeof service, "object"); + assert.equal(typeof handlers[METHOD_SEND_C2C_MESSAGE_FULL], "function"); + assert.equal(typeof handlers[METHOD_NORMALIZE_EVENT_FULL], "function"); +}); + +test("GetAccessToken posts AppID and AppSecret to official token endpoint", async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return response(200, JSON.stringify({ + access_token: "access-1", + expires_in: "7200", + })); + }); + + const result = await rpcdef(buildCtx())[METHOD_GET_ACCESS_TOKEN_PATH]({}); + + assert.equal(captured.url, "https://bots.qq.com/app/getAppAccessToken"); + assert.equal(captured.init.method, "POST"); + assert.ok(captured.init.signal instanceof AbortSignal); + assert.equal("timeoutMs" in captured.init, false); + assert.deepEqual(JSON.parse(captured.init.body), { + appId: "app-1", + clientSecret: "secret-1", + }); + assert.equal(result.success, true); + assert.equal(result.access_token, "access-1"); + assert.equal(result.expires_in, 7200); +}); + +test("SendC2CMessage fetches token and posts to /v2/users/{openid}/messages", async () => { + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: String(url), init }); + if (calls.length === 1) { + return response(200, JSON.stringify({ + access_token: "access-1", + expires_in: 7200, + })); + } + return response(200, JSON.stringify({ + id: "msg-1", + timestamp: 1710000000, + })); + }); + + const result = await rpcdef(buildCtx())[METHOD_SEND_C2C_MESSAGE_PATH]({ + openid: "USER_OPENID", + content: "hello", + msg_id: "incoming-1", + msg_seq: 2, + }); + + assert.equal(calls.length, 2); + assert.ok(calls[0].init.signal instanceof AbortSignal); + assert.equal("timeoutMs" in calls[0].init, false); + assert.equal(calls[1].url, "https://api.sgroup.qq.com/v2/users/USER_OPENID/messages"); + assert.ok(calls[1].init.signal instanceof AbortSignal); + assert.equal("timeoutMs" in calls[1].init, false); + assert.equal(calls[1].init.headers.authorization, "QQBot access-1"); + assert.deepEqual(JSON.parse(calls[1].init.body), { + msg_type: 0, + content: "hello", + msg_id: "incoming-1", + msg_seq: 2, + }); + assert.equal(result.success, true); + assert.equal(result.id, "msg-1"); + assert.equal(result.timestamp, "1710000000"); +}); + +test("SendGroupMessage uses configured access token and rich message JSON fields", async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return response(200, JSON.stringify({ + id: "group-msg-1", + timestamp: "2026-06-29T12:00:00+08:00", + })); + }); + + const result = await rpcdef(buildCtx({ + secret: { + accessToken: "configured-token", + }, + }))[METHOD_SEND_GROUP_MESSAGE_PATH]({ + groupOpenid: "GROUP_OPENID", + content: "# title", + msgType: 2, + markdownJson: '{"content":"# title"}', + keyboardJson: '{"id":"keyboard-template"}', + eventId: "event-1", + }); + + assert.equal(captured.url, "https://api.sgroup.qq.com/v2/groups/GROUP_OPENID/messages"); + assert.equal(captured.init.headers.authorization, "QQBot configured-token"); + assert.deepEqual(JSON.parse(captured.init.body), { + msg_type: 2, + content: "# title", + markdown: { content: "# title" }, + keyboard: { id: "keyboard-template" }, + event_id: "event-1", + }); + assert.equal(result.id, "group-msg-1"); +}); + +test("NormalizeEvent maps official C2C and group payloads", async () => { + const c2cPayload = { + id: "event-1", + op: 0, + s: 42, + t: "C2C_MESSAGE_CREATE", + d: { + id: "ROBOT1.0_msg", + content: "hello", + timestamp: "2023-11-06T13:37:18+08:00", + author: { + user_openid: "USER_OPENID", + }, + }, + }; + + const c2c = await rpcdef(buildCtx())[METHOD_NORMALIZE_EVENT_PATH]({ + payload_json: JSON.stringify(c2cPayload), + }); + + assert.equal(c2c.is_message, true); + assert.equal(c2c.is_c2c, true); + assert.equal(c2c.is_group, false); + assert.equal(c2c.event_id, "event-1"); + assert.equal(c2c.event_type, "C2C_MESSAGE_CREATE"); + assert.equal(c2c.message_id, "ROBOT1.0_msg"); + assert.equal(c2c.openid, "USER_OPENID"); + + const group = await _test.handleNormalizeEvent({ + payload: { + t: "GROUP_AT_MESSAGE_CREATE", + d: { + id: "group-msg", + group_openid: "GROUP_OPENID", + content: " at bot", + author: { + member_openid: "MEMBER_OPENID", + member_role: "owner", + }, + }, + }, + }); + + assert.equal(group.is_group, true); + assert.equal(group.group_openid, "GROUP_OPENID"); + assert.equal(group.openid, "MEMBER_OPENID"); + assert.ok(group.author_json.includes("member_role")); +}); + +test("Gateway receiver starts, buffers messages, polls, and acks", async () => { + const fetchCalls = []; + setFetch(async (url, init) => { + fetchCalls.push({ url: String(url), init }); + return response(200, JSON.stringify({ + url: "wss://api.sgroup.qq.com/websocket", + shards: 1, + })); + }); + + class FakeWebSocket { + static OPEN = 1; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.OPEN; + this.listeners = new Map(); + this.sent = []; + FakeWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open", {})); + } + + addEventListener(name, callback) { + const listeners = this.listeners.get(name) || []; + listeners.push(callback); + this.listeners.set(name, listeners); + } + + send(data) { + this.sent.push(JSON.parse(data)); + } + + close() { + this.readyState = 3; + this.emit("close", {}); + } + + emit(name, event) { + for (const callback of this.listeners.get(name) || []) callback(event); + } + } + + globalThis.WebSocket = FakeWebSocket; + + const ctx = buildCtx({ + secret: { accessToken: "gateway-token" }, + config: { + baseUrl: "https://api.sgroup.qq.com", + gatewayIntents: 1 << 25, + maxBufferedMessages: 5, + }, + }); + + const start = await rpcdef(ctx)[METHOD_START_GATEWAY_PATH]({}); + assert.equal(start.running, true); + assert.equal(fetchCalls[0].url, "https://api.sgroup.qq.com/gateway/bot"); + assert.ok(fetchCalls[0].init.signal instanceof AbortSignal); + assert.equal("timeoutMs" in fetchCalls[0].init, false); + + const socket = FakeWebSocket.instances[0]; + socket.emit("message", { + data: JSON.stringify({ + op: 10, + d: { heartbeat_interval: 100000 }, + }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(socket.sent[0], { + op: 2, + d: { + token: "QQBot gateway-token", + intents: 1 << 25, + shard: [0, 1], + properties: {}, + }, + }); + + socket.emit("message", { + data: JSON.stringify({ + op: 0, + s: 1, + t: "READY", + d: { session_id: "session-1" }, + }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + socket.emit("message", { + data: JSON.stringify({ + id: "event-1", + op: 0, + s: 2, + t: "C2C_MESSAGE_CREATE", + d: { + id: "msg-1", + content: "hello", + author: { user_openid: "USER_OPENID" }, + }, + }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const status = await rpcdef(ctx)[METHOD_GET_GATEWAY_STATUS_PATH]({}); + assert.equal(status.ready, true); + assert.equal(status.session_id, "session-1"); + assert.equal(status.queue_size, 1); + + const polled = await rpcdef(ctx)[METHOD_POLL_MESSAGES_PATH]({ max_messages: 10 }); + assert.equal(polled.messages.length, 1); + assert.equal(polled.messages[0].event.event_type, "C2C_MESSAGE_CREATE"); + assert.equal(polled.messages[0].event.openid, "USER_OPENID"); + + const acked = await rpcdef(ctx)[METHOD_ACK_MESSAGE_PATH]({ localId: [polled.messages[0].local_id] }); + assert.equal(acked.acked, 1); + assert.equal(acked.queue_size, 0); + + const stopped = await rpcdef(ctx)[METHOD_STOP_GATEWAY_PATH]({}); + assert.equal(stopped.running, false); +}); + +test("Gateway ignores stale close events after reconnecting a newer socket", async () => { + setFetch(async () => response(200, JSON.stringify({ + url: "wss://api.sgroup.qq.com/websocket", + shards: 1, + }))); + + class FakeWebSocket { + static OPEN = 1; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.OPEN; + this.listeners = new Map(); + FakeWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open", {})); + } + + addEventListener(name, callback) { + const listeners = this.listeners.get(name) || []; + listeners.push(callback); + this.listeners.set(name, listeners); + } + + send() {} + + close() { + this.readyState = 3; + queueMicrotask(() => this.emit("close", {})); + } + + emit(name, event) { + for (const callback of this.listeners.get(name) || []) callback(event); + } + } + + globalThis.WebSocket = FakeWebSocket; + + const ctx = buildCtx({ + secret: { accessToken: "gateway-token" }, + config: { + baseUrl: "https://api.sgroup.qq.com", + gatewayReconnectMs: 100, + }, + }); + + await rpcdef(ctx)[METHOD_START_GATEWAY_PATH]({}); + const firstSocket = FakeWebSocket.instances[0]; + assert.equal(_test.gatewayState.ws, firstSocket); + + await _test.connectGateway(); + const secondSocket = FakeWebSocket.instances[1]; + assert.equal(FakeWebSocket.instances.length, 2); + assert.equal(_test.gatewayState.ws, secondSocket); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(_test.gatewayState.ws, secondSocket); + assert.equal(_test.gatewayState.reconnectTimer, null); + assert.equal(_test.gatewayState.reconnectCount, 0); +}); + +test("SDK handlers read ctx.request", async () => { + setFetch(async () => response(200, JSON.stringify({ + id: "msg-sdk", + timestamp: 123, + }))); + + const result = await handlers[METHOD_SEND_C2C_MESSAGE_FULL]({ + request: { openid: "USER_OPENID", content: "hello" }, + config: { baseUrl: "https://api.sgroup.qq.com" }, + secret: { accessToken: "token-sdk" }, + }); + + assert.equal(result.id, "msg-sdk"); +}); + +test("validation and upstream failures map to gRPC errors", async () => { + await expectGrpcError( + () => rpcdef({ config: {}, secret: {} })[METHOD_SEND_C2C_MESSAGE_PATH]({ openid: "1", content: "x" }), + "INVALID_ARGUMENT", + grpcStatus.INVALID_ARGUMENT, + ); + + await expectGrpcError( + () => rpcdef(buildCtx({ secret: { accessToken: "token" } }))[METHOD_SEND_C2C_MESSAGE_PATH]({ openid: "", content: "x" }), + "INVALID_ARGUMENT", + grpcStatus.INVALID_ARGUMENT, + ); + + await expectGrpcError( + () => rpcdef(buildCtx({ secret: { accessToken: "token" } }))[METHOD_SEND_GROUP_MESSAGE_PATH]({ + group_openid: "g", + msg_type: 0, + content: "", + }), + "INVALID_ARGUMENT", + grpcStatus.INVALID_ARGUMENT, + ); + + setFetch(async () => response(429, JSON.stringify({ + code: 22009, + message: "msg limit exceed", + }))); + + const err = await expectGrpcError( + () => rpcdef(buildCtx({ secret: { accessToken: "token" } }))[METHOD_SEND_C2C_MESSAGE_PATH]({ openid: "1", content: "x" }), + "UNAVAILABLE", + grpcStatus.UNAVAILABLE, + ); + assert.equal(err.httpStatus, 429); + assert.equal(err.responseCode, 22009); +}); + +test("helpers cover token cache, invalid JSON, and URL normalization", async () => { + assert.equal(_test.buildApiUrl("https://api.sgroup.qq.com", "v2/users/u/messages"), "https://api.sgroup.qq.com/v2/users/u/messages"); + assert.equal(_test.normalizeBaseUrl("https://api.sgroup.qq.com/", "", "baseUrl"), "https://api.sgroup.qq.com"); + assert.equal(_test.resolveTimeoutMs({ bindings: { timeout_ms: "1234" } }), 1234); + assert.throws(() => _test.buildJsonObject("[1]", "markdown_json"), /JSON object/); + assert.throws(() => _test.parseJsonBody("{"), /not valid JSON/); + + const key = _test.tokenCacheKey("url", "app", "secret"); + _test.writeTokenCache(key, "cached", 7200, 1000); + assert.equal(_test.readTokenCache(key, 1000), "cached"); + assert.equal(_test.readTokenCache(key, 7200 * 1000), ""); +}); diff --git a/services/tests/validate-service-package.test.mjs b/services/tests/validate-service-package.test.mjs index 82c1b825..e07f6a36 100644 --- a/services/tests/validate-service-package.test.mjs +++ b/services/tests/validate-service-package.test.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import test from "node:test"; import { discoverServices, main as importCheckMain } from "../scripts/import-check-all.mjs"; +import { checkRegistry, main as registryMain } from "../scripts/generate-service-registry.mjs"; import { main as runCoverageAllMain } from "../scripts/run-coverage-all.mjs"; import { main as runTestsMain, buildNodeTestArgs } from "../scripts/run-tests.mjs"; import { main as validateMain, validateRepository } from "../scripts/validate-service-package.mjs"; @@ -112,6 +113,19 @@ test("validates a migrated external service package root", () => { assert.deepEqual(result.errors, []); }); +test("generates and checks the aggregate service registry", () => { + const root = fixture(); + assert.equal(registryMain(["--root", root]), 0); + assert.equal(registryMain(["--root", root, "--check"]), 0); + assert.deepEqual(validateRepository(root, { serviceDir: "chaitin__safeline-waf" }).errors, []); + + fs.chmodSync(path.join(root, "bin", "safeline-waf.js"), 0o644); + assert.match( + checkRegistry(root).errors.join("\n"), + /bin\/safeline-waf\.js must be executable/, + ); +}); + test("requires executable root wrappers and service entries", () => { const root = fixture(); fs.chmodSync(path.join(root, "bin", "safeline-waf.js"), 0o644);