@nubemclaw/channel-synology-chat 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +59 -0
- package/dist/channel.d.ts +30 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +43 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
- package/src/channel.test.ts +140 -0
- package/src/channel.ts +85 -0
- package/src/index.ts +1 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# LICENSE — NubemClaw v3
|
|
2
|
+
|
|
3
|
+
**Copyright © 2026 Nubemsystems S.L.** All rights reserved.
|
|
4
|
+
|
|
5
|
+
NubemClaw v3 (this monorepo and every `@nubemclaw/*` package published
|
|
6
|
+
under it on the npm registry) is **proprietary software** distributed
|
|
7
|
+
publicly under the SPDX identifier **`UNLICENSED`**. Public
|
|
8
|
+
distribution via npm does NOT make this an open-source project. The
|
|
9
|
+
absence of a permissive license is intentional — the source is
|
|
10
|
+
visible for transparency and operator use, but the rights below apply.
|
|
11
|
+
|
|
12
|
+
## Permitted
|
|
13
|
+
|
|
14
|
+
- Installing and running the published `@nubemclaw/*` packages from
|
|
15
|
+
npm for personal, internal-business, or research use.
|
|
16
|
+
- Reading the source code on the public repository.
|
|
17
|
+
- Submitting issues, pull requests, and feedback to the upstream
|
|
18
|
+
repository (`nubemsystemsdev/NubemClaw-v3`). Contributions are
|
|
19
|
+
accepted under the Contributor License Agreement (CLA) gated at PR
|
|
20
|
+
time; by submitting a contribution you grant Nubemsystems a
|
|
21
|
+
perpetual, worldwide, non-exclusive license to use the contribution
|
|
22
|
+
under this proprietary license.
|
|
23
|
+
|
|
24
|
+
## NOT permitted (without prior written authorisation from Nubemsystems S.L.)
|
|
25
|
+
|
|
26
|
+
- Redistribution of the source or binaries outside the official npm
|
|
27
|
+
registry / GitHub release artifacts.
|
|
28
|
+
- Forking and republishing under a different name or scope.
|
|
29
|
+
- Removing or modifying the copyright notice in this file or in the
|
|
30
|
+
package manifests.
|
|
31
|
+
- Selling, sublicensing, or offering as a hosted SaaS that competes
|
|
32
|
+
with Nubemsystems' commercial offerings.
|
|
33
|
+
- Reverse-engineering the cloud control plane (when one exists) or
|
|
34
|
+
any non-source components shipped alongside.
|
|
35
|
+
|
|
36
|
+
## Trademarks
|
|
37
|
+
|
|
38
|
+
"NubemClaw", "Nubemsystems", and associated logos are trademarks of
|
|
39
|
+
Nubemsystems S.L. They are not licensed by this document — using them
|
|
40
|
+
to identify your fork, derivative, or service requires explicit
|
|
41
|
+
written permission.
|
|
42
|
+
|
|
43
|
+
## Warranty disclaimer
|
|
44
|
+
|
|
45
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
46
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
47
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
48
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
49
|
+
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
50
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
51
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
52
|
+
SOFTWARE.
|
|
53
|
+
|
|
54
|
+
## Contact
|
|
55
|
+
|
|
56
|
+
Licensing inquiries: `licensing@nubemsystems.es`.
|
|
57
|
+
|
|
58
|
+
Operator (single point of contact for v3): José Luis Manzanares
|
|
59
|
+
Fernández, Nubemsystems S.L. (`joseluis.manzanares@nubemsystems.es`).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Channel, type ChannelTarget, type RestFetch } from "@nubemclaw/channel-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* F31.b — Synology Chat channel adapter (F31-fix.1: real provider body shape).
|
|
4
|
+
*
|
|
5
|
+
* Synology Chat incoming webhook (payload form).
|
|
6
|
+
*
|
|
7
|
+
* Endpoint:
|
|
8
|
+
* POST https://synology.local/webapi/entry.cgi
|
|
9
|
+
* (no auth header; token folded into URL or body)
|
|
10
|
+
* Body: ({ payload: JSON.stringify({ text }) })
|
|
11
|
+
*
|
|
12
|
+
* The package OWNS its lifecycle: it does NOT inherit from a unified
|
|
13
|
+
* factory across the 21 F31.b adapters. `createRestChannelBase`
|
|
14
|
+
* (channel-sdk) is a code-reuse helper for the HTTP send path; each
|
|
15
|
+
* package wires its own auth + body + (future) inbound parser.
|
|
16
|
+
*/
|
|
17
|
+
export interface SynologyChatChannelConfig {
|
|
18
|
+
readonly token: string;
|
|
19
|
+
readonly endpoint?: string;
|
|
20
|
+
readonly fetch?: RestFetch;
|
|
21
|
+
readonly capabilities?: import("@nubemclaw/channel-sdk").ChannelCapabilities;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_SYNOLOGYCHAT_ENDPOINT = "https://synology.local/webapi/entry.cgi";
|
|
24
|
+
export declare const buildSendBody: (_target: ChannelTarget, text: string) => Record<string, unknown>;
|
|
25
|
+
export declare const buildSendRequest: (endpoint: string, _token: string, target: ChannelTarget, text: string) => {
|
|
26
|
+
url: string;
|
|
27
|
+
init: RequestInit;
|
|
28
|
+
};
|
|
29
|
+
export declare const createSynologyChatChannel: (config: SynologyChatChannelConfig) => Channel;
|
|
30
|
+
//# sourceMappingURL=channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,OAAO,EACZ,KAAK,aAAa,EAClB,KAAK,SAAS,EAGf,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,wBAAwB,EAAE,mBAAmB,CAAC;CAC9E;AAED,eAAO,MAAM,6BAA6B,4CAA4C,CAAC;AAKvF,eAAO,MAAM,aAAa,GAAI,SAAS,aAAa,EAAE,MAAM,MAAM,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAEzF,CAAC;AAEH,eAAO,MAAM,gBAAgB,GAC3B,UAAU,MAAM,EAChB,QAAQ,MAAM,EACd,QAAQ,aAAa,EACrB,MAAM,MAAM,KACX;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAalC,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAI,QAAQ,yBAAyB,KAAG,OAqB7E,CAAC"}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TEXT_ONLY_REST_CAPABILITIES, createRestChannelBase, extractMessageText, } from "@nubemclaw/channel-sdk";
|
|
2
|
+
export const DEFAULT_SYNOLOGYCHAT_ENDPOINT = "https://synology.local/webapi/entry.cgi";
|
|
3
|
+
const replaceTargetPlaceholder = (endpoint, target) => endpoint.replace(/\{target\}/g, encodeURIComponent(target.id));
|
|
4
|
+
export const buildSendBody = (_target, text) => ({
|
|
5
|
+
payload: JSON.stringify({ text }),
|
|
6
|
+
});
|
|
7
|
+
export const buildSendRequest = (endpoint, _token, target, text) => {
|
|
8
|
+
const url = replaceTargetPlaceholder(endpoint, target);
|
|
9
|
+
const headers = { "content-type": "application/json" };
|
|
10
|
+
// auth folded into URL / body / form payload — no Authorization header.
|
|
11
|
+
return {
|
|
12
|
+
url,
|
|
13
|
+
init: {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers,
|
|
16
|
+
body: JSON.stringify(buildSendBody(target, text)),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export const createSynologyChatChannel = (config) => {
|
|
21
|
+
const endpoint = config.endpoint ?? DEFAULT_SYNOLOGYCHAT_ENDPOINT;
|
|
22
|
+
const sendImpl = async (args) => {
|
|
23
|
+
const text = extractMessageText(args.message);
|
|
24
|
+
const { url, init } = buildSendRequest(endpoint, config.token, args.target, text);
|
|
25
|
+
const res = await args.fetch(url, init);
|
|
26
|
+
let messageId;
|
|
27
|
+
try {
|
|
28
|
+
const body = (await res.json());
|
|
29
|
+
messageId = body.id ?? body.messageId ?? body.ts;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Some channels return text/204 — that's fine.
|
|
33
|
+
}
|
|
34
|
+
return messageId !== undefined ? { status: res.status, messageId } : { status: res.status };
|
|
35
|
+
};
|
|
36
|
+
return createRestChannelBase({
|
|
37
|
+
id: "synology-chat",
|
|
38
|
+
capabilities: config.capabilities ?? TEXT_ONLY_REST_CAPABILITIES,
|
|
39
|
+
send: sendImpl,
|
|
40
|
+
...(config.fetch !== undefined ? { fetch: config.fetch } : {}),
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=channel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,qBAAqB,EACrB,kBAAkB,GAMnB,MAAM,wBAAwB,CAAC;AAyBhC,MAAM,CAAC,MAAM,6BAA6B,GAAG,yCAAyC,CAAC;AAEvF,MAAM,wBAAwB,GAAG,CAAC,QAAgB,EAAE,MAAqB,EAAU,EAAE,CACnF,QAAQ,CAAC,OAAO,CAAC,aAAa,EAAE,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;AAEjE,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,OAAsB,EAAE,IAAY,EAA2B,EAAE,CAAC,CAAC;IAC/F,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,CAAC;CAClC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,QAAgB,EAChB,MAAc,EACd,MAAqB,EACrB,IAAY,EACwB,EAAE;IACtC,MAAM,GAAG,GAAG,wBAAwB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACvD,MAAM,OAAO,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC;IAC/E,wEAAwE;IAExE,OAAO;QACL,GAAG;QACH,IAAI,EAAE;YACJ,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;SAClD;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,MAAiC,EAAW,EAAE;IACtF,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,6BAA6B,CAAC;IAClE,MAAM,QAAQ,GAAG,KAAK,EAAE,IAAkB,EAA2B,EAAE;QACrE,MAAM,IAAI,GAAG,kBAAkB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC9C,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,gBAAgB,CAAC,QAAQ,EAAE,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAClF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACxC,IAAI,SAA6B,CAAC;QAClC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAqD,CAAC;YACpF,SAAS,GAAG,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,EAAE,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,+CAA+C;QACjD,CAAC;QACD,OAAO,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;IAC9F,CAAC,CAAC;IACF,OAAO,qBAAqB,CAAC;QAC3B,EAAE,EAAE,eAAe;QACnB,YAAY,EAAE,MAAM,CAAC,YAAY,IAAI,2BAA2B;QAChE,IAAI,EAAE,QAAQ;QACd,GAAG,CAAC,MAAM,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/D,CAAC,CAAC;AACL,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nubemclaw/channel-synology-chat",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "NubemClaw v3 — Synology Chat channel (F31.b). Synology Chat incoming webhook.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"zod": "^3.23.8",
|
|
21
|
+
"@nubemclaw/channel-sdk": "2.0.0",
|
|
22
|
+
"@nubemclaw/core": "2.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -b",
|
|
26
|
+
"clean": "tsc -b --clean && rm -rf dist .cache"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSynologyChatChannel,
|
|
5
|
+
buildSendBody,
|
|
6
|
+
buildSendRequest,
|
|
7
|
+
DEFAULT_SYNOLOGYCHAT_ENDPOINT,
|
|
8
|
+
} from "./channel.js";
|
|
9
|
+
import type { ChannelDeps } from "@nubemclaw/channel-sdk";
|
|
10
|
+
|
|
11
|
+
const fakeDeps = (): ChannelDeps => ({
|
|
12
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
13
|
+
state: { get: async () => undefined, set: async () => {}, delete: async () => {} },
|
|
14
|
+
allowlist: { allows: () => true, size: 0 },
|
|
15
|
+
onInbound: async () => {},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("createSynologyChatChannel", () => {
|
|
19
|
+
it("exposes a working Channel with id synology-chat", () => {
|
|
20
|
+
const ch = createSynologyChatChannel({ token: "t" });
|
|
21
|
+
expect(ch.id).toBe("synology-chat");
|
|
22
|
+
expect(ch.capabilities.mediaTypes).toContain("text");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("DEFAULT endpoint constant is the canonical provider URL", () => {
|
|
26
|
+
expect(DEFAULT_SYNOLOGYCHAT_ENDPOINT).toContain("synology.local");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("buildSendBody returns the provider-real body shape (audit-1 finding 1)", () => {
|
|
30
|
+
const body = buildSendBody({ kind: "chat", id: "target-1" }, "hello") as Record<
|
|
31
|
+
string,
|
|
32
|
+
unknown
|
|
33
|
+
> & {
|
|
34
|
+
chatId?: unknown;
|
|
35
|
+
content?: unknown;
|
|
36
|
+
channel?: unknown;
|
|
37
|
+
text?: unknown;
|
|
38
|
+
to?: unknown;
|
|
39
|
+
messages?: unknown;
|
|
40
|
+
msgtype?: unknown;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
message?: unknown;
|
|
43
|
+
messaging_product?: unknown;
|
|
44
|
+
receive_id?: unknown;
|
|
45
|
+
msg_type?: unknown;
|
|
46
|
+
msgType?: unknown;
|
|
47
|
+
ship?: unknown;
|
|
48
|
+
app?: unknown;
|
|
49
|
+
recipient?: unknown;
|
|
50
|
+
conversation_id?: unknown;
|
|
51
|
+
payload?: unknown;
|
|
52
|
+
touser?: unknown;
|
|
53
|
+
number?: unknown;
|
|
54
|
+
recipients?: unknown;
|
|
55
|
+
type?: unknown;
|
|
56
|
+
broadcaster_id?: unknown;
|
|
57
|
+
sender_id?: unknown;
|
|
58
|
+
json?: unknown;
|
|
59
|
+
content_type?: unknown;
|
|
60
|
+
agentid?: unknown;
|
|
61
|
+
chatGuid?: unknown;
|
|
62
|
+
method?: unknown;
|
|
63
|
+
};
|
|
64
|
+
expect(JSON.parse(body.payload).text).toBe("hello");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("send() POSTs to the channel endpoint with token-bound auth", async () => {
|
|
68
|
+
const fetchSpy = vi.fn(
|
|
69
|
+
async () =>
|
|
70
|
+
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
|
|
71
|
+
);
|
|
72
|
+
const ch = createSynologyChatChannel({
|
|
73
|
+
token: "tok-1",
|
|
74
|
+
fetch: fetchSpy as unknown as typeof fetch,
|
|
75
|
+
});
|
|
76
|
+
await ch.init(fakeDeps());
|
|
77
|
+
await ch.send({ kind: "chat", id: "target-1" }, { type: "text", text: "hello" });
|
|
78
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
79
|
+
const call = fetchSpy.mock.calls.at(0);
|
|
80
|
+
const init = call?.[1] as RequestInit | undefined;
|
|
81
|
+
expect(init?.method).toBe("POST");
|
|
82
|
+
const body = JSON.parse((init?.body ?? "{}") as string) as Record<string, unknown> & {
|
|
83
|
+
chatId?: unknown;
|
|
84
|
+
content?: unknown;
|
|
85
|
+
channel?: unknown;
|
|
86
|
+
text?: unknown;
|
|
87
|
+
to?: unknown;
|
|
88
|
+
messages?: unknown;
|
|
89
|
+
msgtype?: unknown;
|
|
90
|
+
body?: unknown;
|
|
91
|
+
message?: unknown;
|
|
92
|
+
messaging_product?: unknown;
|
|
93
|
+
receive_id?: unknown;
|
|
94
|
+
msg_type?: unknown;
|
|
95
|
+
msgType?: unknown;
|
|
96
|
+
ship?: unknown;
|
|
97
|
+
app?: unknown;
|
|
98
|
+
recipient?: unknown;
|
|
99
|
+
conversation_id?: unknown;
|
|
100
|
+
payload?: unknown;
|
|
101
|
+
touser?: unknown;
|
|
102
|
+
number?: unknown;
|
|
103
|
+
recipients?: unknown;
|
|
104
|
+
type?: unknown;
|
|
105
|
+
broadcaster_id?: unknown;
|
|
106
|
+
sender_id?: unknown;
|
|
107
|
+
json?: unknown;
|
|
108
|
+
content_type?: unknown;
|
|
109
|
+
agentid?: unknown;
|
|
110
|
+
chatGuid?: unknown;
|
|
111
|
+
method?: unknown;
|
|
112
|
+
};
|
|
113
|
+
expect(JSON.parse(body.payload).text).toBe("hello");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("send() throws on non-2xx response", async () => {
|
|
117
|
+
const fetchSpy = vi.fn(async () => new Response("server error", { status: 500 }));
|
|
118
|
+
const ch = createSynologyChatChannel({
|
|
119
|
+
token: "tok",
|
|
120
|
+
fetch: fetchSpy as unknown as typeof fetch,
|
|
121
|
+
});
|
|
122
|
+
await ch.init(fakeDeps());
|
|
123
|
+
await expect(ch.send({ kind: "chat", id: "x" }, { type: "text", text: "msg" })).rejects.toThrow(
|
|
124
|
+
/synology-chat send failed with status 500/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("buildSendRequest substitutes {target} placeholders when present", () => {
|
|
129
|
+
const out = buildSendRequest(
|
|
130
|
+
"https://api.example/{target}/send",
|
|
131
|
+
"tok",
|
|
132
|
+
{ kind: "chat", id: "abc123" },
|
|
133
|
+
"hi",
|
|
134
|
+
);
|
|
135
|
+
if (DEFAULT_SYNOLOGYCHAT_ENDPOINT.includes("{target}")) {
|
|
136
|
+
expect(out.url).toContain("abc123");
|
|
137
|
+
}
|
|
138
|
+
expect(out.init.method).toBe("POST");
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TEXT_ONLY_REST_CAPABILITIES,
|
|
3
|
+
createRestChannelBase,
|
|
4
|
+
extractMessageText,
|
|
5
|
+
type Channel,
|
|
6
|
+
type ChannelTarget,
|
|
7
|
+
type RestFetch,
|
|
8
|
+
type RestSendArgs,
|
|
9
|
+
type RestSendResult,
|
|
10
|
+
} from "@nubemclaw/channel-sdk";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* F31.b — Synology Chat channel adapter (F31-fix.1: real provider body shape).
|
|
14
|
+
*
|
|
15
|
+
* Synology Chat incoming webhook (payload form).
|
|
16
|
+
*
|
|
17
|
+
* Endpoint:
|
|
18
|
+
* POST https://synology.local/webapi/entry.cgi
|
|
19
|
+
* (no auth header; token folded into URL or body)
|
|
20
|
+
* Body: ({ payload: JSON.stringify({ text }) })
|
|
21
|
+
*
|
|
22
|
+
* The package OWNS its lifecycle: it does NOT inherit from a unified
|
|
23
|
+
* factory across the 21 F31.b adapters. `createRestChannelBase`
|
|
24
|
+
* (channel-sdk) is a code-reuse helper for the HTTP send path; each
|
|
25
|
+
* package wires its own auth + body + (future) inbound parser.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface SynologyChatChannelConfig {
|
|
29
|
+
readonly token: string;
|
|
30
|
+
readonly endpoint?: string;
|
|
31
|
+
readonly fetch?: RestFetch;
|
|
32
|
+
readonly capabilities?: import("@nubemclaw/channel-sdk").ChannelCapabilities;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_SYNOLOGYCHAT_ENDPOINT = "https://synology.local/webapi/entry.cgi";
|
|
36
|
+
|
|
37
|
+
const replaceTargetPlaceholder = (endpoint: string, target: ChannelTarget): string =>
|
|
38
|
+
endpoint.replace(/\{target\}/g, encodeURIComponent(target.id));
|
|
39
|
+
|
|
40
|
+
export const buildSendBody = (_target: ChannelTarget, text: string): Record<string, unknown> => ({
|
|
41
|
+
payload: JSON.stringify({ text }),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const buildSendRequest = (
|
|
45
|
+
endpoint: string,
|
|
46
|
+
_token: string,
|
|
47
|
+
target: ChannelTarget,
|
|
48
|
+
text: string,
|
|
49
|
+
): { url: string; init: RequestInit } => {
|
|
50
|
+
const url = replaceTargetPlaceholder(endpoint, target);
|
|
51
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
52
|
+
// auth folded into URL / body / form payload — no Authorization header.
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
url,
|
|
56
|
+
init: {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body: JSON.stringify(buildSendBody(target, text)),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const createSynologyChatChannel = (config: SynologyChatChannelConfig): Channel => {
|
|
65
|
+
const endpoint = config.endpoint ?? DEFAULT_SYNOLOGYCHAT_ENDPOINT;
|
|
66
|
+
const sendImpl = async (args: RestSendArgs): Promise<RestSendResult> => {
|
|
67
|
+
const text = extractMessageText(args.message);
|
|
68
|
+
const { url, init } = buildSendRequest(endpoint, config.token, args.target, text);
|
|
69
|
+
const res = await args.fetch(url, init);
|
|
70
|
+
let messageId: string | undefined;
|
|
71
|
+
try {
|
|
72
|
+
const body = (await res.json()) as { id?: string; messageId?: string; ts?: string };
|
|
73
|
+
messageId = body.id ?? body.messageId ?? body.ts;
|
|
74
|
+
} catch {
|
|
75
|
+
// Some channels return text/204 — that's fine.
|
|
76
|
+
}
|
|
77
|
+
return messageId !== undefined ? { status: res.status, messageId } : { status: res.status };
|
|
78
|
+
};
|
|
79
|
+
return createRestChannelBase({
|
|
80
|
+
id: "synology-chat",
|
|
81
|
+
capabilities: config.capabilities ?? TEXT_ONLY_REST_CAPABILITIES,
|
|
82
|
+
send: sendImpl,
|
|
83
|
+
...(config.fetch !== undefined ? { fetch: config.fetch } : {}),
|
|
84
|
+
});
|
|
85
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./channel.js";
|