openclaw-msg9 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ice5kysl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # openclaw-msg9
2
+
3
+ OpenClaw channel plugin for [msg9.io](https://msg9.io) agent inboxes. Turns a
4
+ msg9 agent inbox into an OpenClaw channel: inbound mail drives the agent,
5
+ agent replies are delivered back on the original msg9 thread.
6
+
7
+ - **Channel id:** `msg9` (package / plugin id: `openclaw-msg9`)
8
+ - **Transport:** poll-based inbound (`agent.unread()`), direct outbound (`agent.send()`)
9
+ - **Content:** text only — msg9 has no attachments, so no `sendMedia`
10
+
11
+ ## Configuration
12
+
13
+ Single account (`channels.msg9`):
14
+
15
+ ```json5
16
+ {
17
+ channels: {
18
+ msg9: {
19
+ enabled: true,
20
+ address: "my-project@kimi.ice.msg9.io", // your agent inbox address
21
+ apiKey: "msg9_sk_...", // the inbox's agent key — a secret
22
+ // baseUrl: "https://api.msg9.io", // optional, this is the default
23
+ pollIntervalMs: 3000, // inbox poll interval (default 3000)
24
+ allowFrom: ["alice@claude.ice.msg9.io"], // optional sender whitelist
25
+ },
26
+ },
27
+ }
28
+ ```
29
+
30
+ Multi-account:
31
+
32
+ ```json5
33
+ {
34
+ channels: {
35
+ msg9: {
36
+ enabled: true,
37
+ address: "main@kimi.ice.msg9.io",
38
+ apiKey: "msg9_sk_...",
39
+ accounts: {
40
+ side: {
41
+ address: "side@kimi.ice.msg9.io",
42
+ apiKey: "msg9_sk_...",
43
+ pollIntervalMs: 1000,
44
+ },
45
+ },
46
+ },
47
+ },
48
+ }
49
+ ```
50
+
51
+ Or via environment variables (default account only):
52
+ `MSG9_ADDRESS`, `MSG9_API_KEY`, `MSG9_BASE_URL`.
53
+
54
+ CLI-style setup tokens follow the same shape as other channels:
55
+ `--token "address,apiKey[,baseUrl]"`, or `--use-env` to read the env vars above.
56
+
57
+ ## Addressing
58
+
59
+ Outbound targets use the `msg9:` prefix:
60
+
61
+ ```
62
+ msg9:alice@claude.ice.msg9.io
63
+ ```
64
+
65
+ `normalizeTarget` strips the prefix; a bare address containing `@` is also
66
+ recognized as a msg9 id (`<user>@<pod>.msg9.io`).
67
+
68
+ ## Inbound semantics
69
+
70
+ Each poll sweep drains the inbox's unread folder:
71
+
72
+ 1. **Dedupe** by `message_id` (bounded in-memory set, reset at 1000 entries).
73
+ The platform is at-least-once; the set covers a gateway run.
74
+ 2. **allowFrom** — when the list is non-empty and the sender is not listed,
75
+ the message is `markRead` and skipped: it never reaches the agent, but
76
+ stays in the owner's unprocessed folder for manual review. Empty allowFrom
77
+ allows everyone — configure it before enabling text commands.
78
+ 3. **markProcessed** — allowed messages are marked processed (implies read,
79
+ idempotent) *before* dispatch, so the agent is driven at-most-once even if
80
+ dispatch or delivery fails.
81
+ 4. **Dispatch** — the standard channel pipeline
82
+ (`activity.record` → `resolveAgentRoute` → envelope → `finalizeInboundContext`
83
+ → `dispatchReplyWithBufferedBlockDispatcher`). The `deliver` callback sends
84
+ via `agent.send({ ..., reply_to: <original message_id> })`.
85
+
86
+ ### reply_to auto-close
87
+
88
+ Sending a reply with `reply_to` set makes the server close the original
89
+ message automatically. Combined with the mark-processed-first policy, replied
90
+ threads are closed exactly once, and messages that never get a reply remain
91
+ visible in the unprocessed folder.
92
+
93
+ ## Outbound
94
+
95
+ `outbound.sendText` resolves the account, strips the `msg9:` prefix, and calls
96
+ `agent.send`. A core-provided `replyToId` (the inbound `MessageSid`) is mapped
97
+ to msg9 `reply_to`. Returns `{ channel: "msg9", messageId }`. There is no
98
+ `sendMedia` — msg9 carries text bodies only.
99
+
100
+ ## Credential hygiene
101
+
102
+ `apiKey` is a `msg9_sk_...` agent key: anyone holding it can read and send as
103
+ that inbox. Keep it in OpenClaw's config/secret stores with restricted file
104
+ permissions (0600), never in a repo or a chat log. If a key leaks in
105
+ plaintext, rotate it at the platform before reusing the inbox.
106
+
107
+ ## Requirements
108
+
109
+ - Node.js >= 18
110
+ - OpenClaw plugin API `>=2026.3.23-1` (built against SDK `2026.4.26`)
111
+
112
+ ## Development
113
+
114
+ ```sh
115
+ npm install
116
+ npm run build # tsc → dist/
117
+ npm test # vitest (no network — the client factory is mocked)
118
+ npm run pack:dry-run
119
+ ```
120
+
121
+ ## Roadmap
122
+
123
+ - **Webhook inbound** — replace polling with tenant-key webhook subscriptions
124
+ (`registerHttpRoute` + signature verification) once a webhook is provisioned.
125
+ - **Inbox tools** — expose `list_inbox` / `mark_processed` style tools to the
126
+ agent via `registerTool`.
127
+ - **ClawHub publish** — publishing pipeline (`openclaw.plugin.json` already
128
+ carries the required `compat`/`build` metadata; schema is fully flat, no
129
+ `$ref`/`$defs`).
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import { msg9Plugin } from "./src/channel.js";
3
+ declare const entry: {
4
+ id: string;
5
+ name: string;
6
+ description: string;
7
+ configSchema: NonNullable<typeof msg9Plugin.configSchema>;
8
+ register: (api: OpenClawPluginApi) => void;
9
+ channelPlugin: typeof msg9Plugin;
10
+ setChannelRuntime?: (runtime: PluginRuntime) => void;
11
+ };
12
+ export default entry;
13
+ export { msg9Plugin } from "./src/channel.js";
14
+ export { setMsg9Runtime, clearMsg9Runtime, getMsg9Runtime, tryGetMsg9Runtime } from "./src/runtime.js";
15
+ export * from "./src/types.js";
16
+ export * from "./src/config.js";
17
+ export * from "./src/gateway.js";
18
+ export * from "./src/client.js";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { msg9Plugin } from "./src/channel.js";
3
+ import { setMsg9Runtime } from "./src/runtime.js";
4
+ const entry = defineChannelPluginEntry({
5
+ id: "openclaw-msg9",
6
+ name: "msg9",
7
+ description: "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
8
+ plugin: msg9Plugin,
9
+ setRuntime: setMsg9Runtime,
10
+ });
11
+ export default entry;
12
+ export { msg9Plugin } from "./src/channel.js";
13
+ export { setMsg9Runtime, clearMsg9Runtime, getMsg9Runtime, tryGetMsg9Runtime } from "./src/runtime.js";
14
+ export * from "./src/types.js";
15
+ export * from "./src/config.js";
16
+ export * from "./src/gateway.js";
17
+ export * from "./src/client.js";
@@ -0,0 +1,11 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { msg9Plugin } from "./src/channel.js";
3
+ declare const setupEntry: {
4
+ id: string;
5
+ name: string;
6
+ description: string;
7
+ configSchema: NonNullable<typeof msg9Plugin.configSchema>;
8
+ register: (api: OpenClawPluginApi) => void;
9
+ channelPlugin: typeof msg9Plugin;
10
+ };
11
+ export default setupEntry;
@@ -0,0 +1,9 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { msg9Plugin } from "./src/channel.js";
3
+ const setupEntry = defineChannelPluginEntry({
4
+ id: "openclaw-msg9",
5
+ name: "msg9",
6
+ description: "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
7
+ plugin: msg9Plugin,
8
+ });
9
+ export default setupEntry;
@@ -0,0 +1,3 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import type { ResolvedMsg9Account } from "./types.js";
3
+ export declare const msg9Plugin: ChannelPlugin<ResolvedMsg9Account>;
@@ -0,0 +1,142 @@
1
+ import { listMsg9AccountIds, resolveMsg9Account, applyMsg9AccountConfig, isMsg9AccountConfigured, readMsg9EnvFallbacks, } from "./config.js";
2
+ import { createMsg9Agent } from "./client.js";
3
+ import { startGateway } from "./gateway.js";
4
+ const DEFAULT_ACCOUNT_ID = "default";
5
+ export const msg9Plugin = {
6
+ id: "msg9",
7
+ meta: {
8
+ id: "msg9",
9
+ label: "msg9",
10
+ selectionLabel: "msg9.io agent inbox",
11
+ docsPath: "/docs/channels/msg9",
12
+ blurb: "Connect to a msg9.io agent inbox (poll-based, reply-thread closing)",
13
+ order: 56,
14
+ },
15
+ capabilities: {
16
+ chatTypes: ["direct"],
17
+ media: false,
18
+ reactions: false,
19
+ threads: false,
20
+ blockStreaming: false,
21
+ },
22
+ reload: { configPrefixes: ["channels.msg9"] },
23
+ messaging: {
24
+ normalizeTarget: (target) => {
25
+ return target.replace(/^msg9:/i, "");
26
+ },
27
+ targetResolver: {
28
+ looksLikeId: (id) => {
29
+ return id.replace(/^msg9:/i, "").includes("@");
30
+ },
31
+ hint: "<user>@<pod>.msg9.io",
32
+ },
33
+ },
34
+ config: {
35
+ listAccountIds: (cfg) => listMsg9AccountIds(cfg),
36
+ resolveAccount: (cfg, accountId) => resolveMsg9Account(cfg, accountId),
37
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
38
+ isConfigured: (account) => isMsg9AccountConfigured(account),
39
+ describeAccount: (account) => ({
40
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
41
+ name: account?.name,
42
+ enabled: account?.enabled ?? false,
43
+ configured: isMsg9AccountConfigured(account),
44
+ }),
45
+ },
46
+ setup: {
47
+ validateInput: ({ input }) => {
48
+ if (!input.token && !input.useEnv) {
49
+ return "msg9 requires --token (format: address,apiKey[,baseUrl]) or --use-env (MSG9_ADDRESS, MSG9_API_KEY[, MSG9_BASE_URL])";
50
+ }
51
+ return null;
52
+ },
53
+ applyAccountConfig: ({ cfg, accountId, input }) => {
54
+ let address = "";
55
+ let apiKey = "";
56
+ let baseUrl;
57
+ if (input.token) {
58
+ const parts = input.token.split(",");
59
+ address = parts[0]?.trim() ?? "";
60
+ apiKey = parts[1]?.trim() ?? "";
61
+ baseUrl = parts[2]?.trim() || undefined;
62
+ }
63
+ if (input.useEnv) {
64
+ const env = readMsg9EnvFallbacks();
65
+ address = address || env.address || "";
66
+ apiKey = apiKey || env.apiKey || "";
67
+ baseUrl = baseUrl || env.baseUrl || undefined;
68
+ }
69
+ return applyMsg9AccountConfig(cfg, accountId, {
70
+ address,
71
+ apiKey,
72
+ baseUrl,
73
+ name: input.name,
74
+ });
75
+ },
76
+ },
77
+ outbound: {
78
+ deliveryMode: "direct",
79
+ textChunkLimit: 4000,
80
+ sendText: async ({ to, text, accountId, replyToId, cfg }) => {
81
+ const account = resolveMsg9Account(cfg, accountId);
82
+ const agent = createMsg9Agent(account);
83
+ const target = to.replace(/^msg9:/i, "");
84
+ const response = await agent.send({
85
+ to: target,
86
+ body: { text },
87
+ ...(replyToId ? { reply_to: replyToId } : {}),
88
+ });
89
+ return {
90
+ channel: "msg9",
91
+ messageId: response.message_id,
92
+ };
93
+ },
94
+ },
95
+ gateway: {
96
+ startAccount: async (ctx) => {
97
+ const { account, abortSignal, log } = ctx;
98
+ log?.info(`[msg9:${account.accountId}] Starting gateway`);
99
+ await startGateway({
100
+ account,
101
+ abortSignal,
102
+ cfg: ctx.cfg,
103
+ log,
104
+ onReady: () => {
105
+ log?.info(`[msg9:${account.accountId}] Gateway ready`);
106
+ ctx.setStatus({
107
+ ...ctx.getStatus(),
108
+ running: true,
109
+ connected: true,
110
+ lastConnectedAt: Date.now(),
111
+ });
112
+ },
113
+ onError: (error) => {
114
+ log?.error(`[msg9:${account.accountId}] Gateway error: ${error.message}`);
115
+ ctx.setStatus({
116
+ ...ctx.getStatus(),
117
+ lastError: error.message,
118
+ });
119
+ },
120
+ });
121
+ },
122
+ },
123
+ status: {
124
+ defaultRuntime: {
125
+ accountId: DEFAULT_ACCOUNT_ID,
126
+ running: false,
127
+ connected: false,
128
+ lastConnectedAt: null,
129
+ lastError: null,
130
+ },
131
+ buildAccountSnapshot: ({ account, runtime }) => ({
132
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
133
+ name: account?.name,
134
+ enabled: account?.enabled ?? false,
135
+ configured: isMsg9AccountConfigured(account),
136
+ running: runtime?.running ?? false,
137
+ connected: runtime?.connected ?? false,
138
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
139
+ lastError: runtime?.lastError ?? null,
140
+ }),
141
+ },
142
+ };
@@ -0,0 +1,16 @@
1
+ import type { Message, SendMessageOptions, SendResponse } from "msg9-io";
2
+ import type { ResolvedMsg9Account } from "./types.js";
3
+ /**
4
+ * The msg9 agent surface this plugin relies on.
5
+ *
6
+ * Factory return type doubles as the test seam: gateway and outbound both
7
+ * construct clients through {@link createMsg9Agent}, so tests stub the factory
8
+ * and never touch the network.
9
+ */
10
+ export interface Msg9AgentClient {
11
+ unread(): AsyncGenerator<Message>;
12
+ send(options: SendMessageOptions): Promise<SendResponse>;
13
+ markRead(id: string): Promise<void>;
14
+ markProcessed(id: string): Promise<void>;
15
+ }
16
+ export declare function createMsg9Agent(account: ResolvedMsg9Account): Msg9AgentClient;
@@ -0,0 +1,29 @@
1
+ import { Agent } from "msg9-io";
2
+ const DEFAULT_BASE_URL = "https://api.msg9.io";
3
+ export function createMsg9Agent(account) {
4
+ const agent = new Agent({
5
+ address: account.address,
6
+ apiKey: account.apiKey,
7
+ ...(account.baseUrl ? { baseUrl: account.baseUrl } : {}),
8
+ });
9
+ const baseUrl = (account.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
10
+ async function postInboxState(suffix) {
11
+ const response = await fetch(`${baseUrl}/api/v1/inbox/messages/${encodeURIComponent(suffix)}`, {
12
+ method: "POST",
13
+ headers: { Authorization: `Bearer ${account.apiKey}` },
14
+ });
15
+ if (!response.ok) {
16
+ throw new Error(`msg9 inbox state update failed: ${response.status} ${response.statusText}`);
17
+ }
18
+ }
19
+ return {
20
+ unread: () => agent.unread(),
21
+ send: (options) => agent.send(options),
22
+ markRead: typeof agent.markRead === "function"
23
+ ? (id) => agent.markRead(id, "agent")
24
+ : (id) => postInboxState(`${id}/read`),
25
+ markProcessed: typeof agent.markProcessed === "function"
26
+ ? (id) => agent.markProcessed(id, "agent")
27
+ : (id) => postInboxState(`${id}/processed`),
28
+ };
29
+ }
@@ -0,0 +1,42 @@
1
+ import type { ResolvedMsg9Account, Msg9ChannelConfig } from "./types.js";
2
+ export declare const DEFAULT_POLL_INTERVAL_MS = 3000;
3
+ interface OpenClawConfig {
4
+ channels?: {
5
+ msg9?: Msg9ChannelConfig;
6
+ [key: string]: unknown;
7
+ };
8
+ [key: string]: unknown;
9
+ }
10
+ /**
11
+ * Environment variable fallbacks (default account only).
12
+ */
13
+ export declare function readMsg9EnvFallbacks(): {
14
+ address?: string;
15
+ apiKey?: string;
16
+ baseUrl?: string;
17
+ };
18
+ /**
19
+ * List all configured msg9 account IDs.
20
+ */
21
+ export declare function listMsg9AccountIds(cfg: OpenClawConfig): string[];
22
+ /**
23
+ * Resolve a msg9 account from config.
24
+ *
25
+ * Environment variable fallbacks (MSG9_ADDRESS, MSG9_API_KEY, MSG9_BASE_URL)
26
+ * apply to the default account only, matching other channels' conventions.
27
+ */
28
+ export declare function resolveMsg9Account(cfg: OpenClawConfig, accountId?: string | null): ResolvedMsg9Account;
29
+ /**
30
+ * Whether a resolved account has the minimum credentials to run.
31
+ */
32
+ export declare function isMsg9AccountConfigured(account: ResolvedMsg9Account | null | undefined): boolean;
33
+ /**
34
+ * Apply account configuration changes (immutable — returns a new cfg).
35
+ */
36
+ export declare function applyMsg9AccountConfig(cfg: OpenClawConfig, accountId: string, input: {
37
+ address?: string;
38
+ apiKey?: string;
39
+ baseUrl?: string;
40
+ name?: string;
41
+ }): OpenClawConfig;
42
+ export {};
@@ -0,0 +1,130 @@
1
+ const DEFAULT_ACCOUNT_ID = "default";
2
+ export const DEFAULT_POLL_INTERVAL_MS = 3000;
3
+ function isAccountPopulated(config) {
4
+ return Boolean(config?.address && config?.apiKey);
5
+ }
6
+ /**
7
+ * Environment variable fallbacks (default account only).
8
+ */
9
+ export function readMsg9EnvFallbacks() {
10
+ return {
11
+ address: process.env.MSG9_ADDRESS || undefined,
12
+ apiKey: process.env.MSG9_API_KEY || undefined,
13
+ baseUrl: process.env.MSG9_BASE_URL || undefined,
14
+ };
15
+ }
16
+ /**
17
+ * List all configured msg9 account IDs.
18
+ */
19
+ export function listMsg9AccountIds(cfg) {
20
+ const ids = new Set();
21
+ const msg9 = cfg.channels?.msg9;
22
+ if (isAccountPopulated(msg9)) {
23
+ ids.add(DEFAULT_ACCOUNT_ID);
24
+ }
25
+ if (msg9?.accounts) {
26
+ for (const accountId of Object.keys(msg9.accounts)) {
27
+ if (isAccountPopulated(msg9.accounts[accountId])) {
28
+ ids.add(accountId);
29
+ }
30
+ }
31
+ }
32
+ return Array.from(ids);
33
+ }
34
+ /**
35
+ * Resolve a msg9 account from config.
36
+ *
37
+ * Environment variable fallbacks (MSG9_ADDRESS, MSG9_API_KEY, MSG9_BASE_URL)
38
+ * apply to the default account only, matching other channels' conventions.
39
+ */
40
+ export function resolveMsg9Account(cfg, accountId) {
41
+ const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
42
+ const msg9 = cfg.channels?.msg9;
43
+ let accountConfig;
44
+ if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
45
+ accountConfig = {
46
+ enabled: msg9?.enabled,
47
+ name: msg9?.name,
48
+ address: msg9?.address,
49
+ apiKey: msg9?.apiKey,
50
+ baseUrl: msg9?.baseUrl,
51
+ pollIntervalMs: msg9?.pollIntervalMs,
52
+ allowFrom: msg9?.allowFrom,
53
+ };
54
+ }
55
+ else {
56
+ accountConfig = msg9?.accounts?.[resolvedAccountId] ?? {};
57
+ }
58
+ let address = accountConfig.address ?? "";
59
+ let apiKey = accountConfig.apiKey ?? "";
60
+ let baseUrl = accountConfig.baseUrl;
61
+ if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
62
+ const env = readMsg9EnvFallbacks();
63
+ if (!address && env.address) {
64
+ address = env.address;
65
+ }
66
+ if (!apiKey && env.apiKey) {
67
+ apiKey = env.apiKey;
68
+ }
69
+ if (!baseUrl && env.baseUrl) {
70
+ baseUrl = env.baseUrl;
71
+ }
72
+ }
73
+ return {
74
+ accountId: resolvedAccountId,
75
+ name: accountConfig.name,
76
+ enabled: accountConfig.enabled !== false,
77
+ address,
78
+ apiKey,
79
+ baseUrl,
80
+ pollIntervalMs: accountConfig.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
81
+ allowFrom: accountConfig.allowFrom,
82
+ config: accountConfig,
83
+ };
84
+ }
85
+ /**
86
+ * Whether a resolved account has the minimum credentials to run.
87
+ */
88
+ export function isMsg9AccountConfigured(account) {
89
+ return Boolean(account?.address && account?.apiKey);
90
+ }
91
+ /**
92
+ * Apply account configuration changes (immutable — returns a new cfg).
93
+ */
94
+ export function applyMsg9AccountConfig(cfg, accountId, input) {
95
+ const next = { ...cfg };
96
+ const msg9 = next.channels?.msg9;
97
+ const patch = {
98
+ enabled: true,
99
+ ...(input.address ? { address: input.address } : {}),
100
+ ...(input.apiKey ? { apiKey: input.apiKey } : {}),
101
+ ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
102
+ ...(input.name ? { name: input.name } : {}),
103
+ };
104
+ if (accountId === DEFAULT_ACCOUNT_ID) {
105
+ next.channels = {
106
+ ...next.channels,
107
+ msg9: {
108
+ ...msg9,
109
+ ...patch,
110
+ },
111
+ };
112
+ }
113
+ else {
114
+ next.channels = {
115
+ ...next.channels,
116
+ msg9: {
117
+ ...msg9,
118
+ enabled: true,
119
+ accounts: {
120
+ ...msg9?.accounts,
121
+ [accountId]: {
122
+ ...msg9?.accounts?.[accountId],
123
+ ...patch,
124
+ },
125
+ },
126
+ },
127
+ };
128
+ }
129
+ return next;
130
+ }
@@ -0,0 +1,30 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+ import type { ResolvedMsg9Account } from "./types.js";
3
+ import { getMsg9Runtime } from "./runtime.js";
4
+ export interface GatewayContext {
5
+ account: ResolvedMsg9Account;
6
+ abortSignal: AbortSignal;
7
+ cfg: OpenClawConfig;
8
+ onReady?: (data: unknown) => void;
9
+ onError?: (error: Error) => void;
10
+ log?: {
11
+ info: (msg: string) => void;
12
+ error: (msg: string) => void;
13
+ debug?: (msg: string) => void;
14
+ };
15
+ }
16
+ export declare function resolveInboundCommandAuthorization(params: {
17
+ pluginRuntime: ReturnType<typeof getMsg9Runtime>;
18
+ cfg: OpenClawConfig;
19
+ allowFrom?: string[];
20
+ peerId: string;
21
+ }): boolean;
22
+ /**
23
+ * Poll the msg9 inbox until the abort signal fires.
24
+ *
25
+ * Each sweep drains `agent.unread()` fully. Every message is deduped by
26
+ * message_id, allowFrom-filtered (non-listed senders are marked read and
27
+ * skipped), then marked processed BEFORE dispatch so the agent is driven
28
+ * at-most-once even if dispatch or delivery fails.
29
+ */
30
+ export declare function startGateway(ctx: GatewayContext): Promise<void>;
@@ -0,0 +1,205 @@
1
+ import { getMsg9Runtime } from "./runtime.js";
2
+ import { createMsg9Agent } from "./client.js";
3
+ // Inbound dedupe — the platform delivers at-least-once, so replays within a
4
+ // gateway run are dropped by message_id. The set is bounded and reset when full.
5
+ const SEEN_MESSAGE_IDS_MAX = 1000;
6
+ export function resolveInboundCommandAuthorization(params) {
7
+ const { pluginRuntime, cfg, allowFrom, peerId } = params;
8
+ const hasAllowFrom = Array.isArray(allowFrom) && allowFrom.length > 0;
9
+ const senderAllowedForCommands = hasAllowFrom && allowFrom.includes(peerId);
10
+ const resolveCommandAuthorized = pluginRuntime.channel.commands?.resolveCommandAuthorizedFromAuthorizers;
11
+ if (typeof resolveCommandAuthorized !== "function") {
12
+ return senderAllowedForCommands;
13
+ }
14
+ // Access groups default on in current SDKs; older gateways exposed the
15
+ // switch as cfg.commands.useAccessGroups, so honor it when present.
16
+ const commandsCfg = cfg.commands;
17
+ const useAccessGroups = commandsCfg?.useAccessGroups ?? true;
18
+ return resolveCommandAuthorized({
19
+ useAccessGroups,
20
+ authorizers: [
21
+ {
22
+ configured: hasAllowFrom,
23
+ allowed: senderAllowedForCommands,
24
+ },
25
+ ],
26
+ modeWhenAccessGroupsOff: hasAllowFrom ? "configured" : "deny",
27
+ });
28
+ }
29
+ function sleepAbortable(ms, abortSignal) {
30
+ return new Promise((resolve) => {
31
+ if (abortSignal.aborted) {
32
+ resolve();
33
+ return;
34
+ }
35
+ const timer = setTimeout(() => {
36
+ abortSignal.removeEventListener("abort", onAbort);
37
+ resolve();
38
+ }, ms);
39
+ const onAbort = () => {
40
+ clearTimeout(timer);
41
+ resolve();
42
+ };
43
+ abortSignal.addEventListener("abort", onAbort, { once: true });
44
+ });
45
+ }
46
+ /**
47
+ * Formats the inbound prompt envelope. SDKs around 2026.4 exposed
48
+ * `formatInboundEnvelope` on the reply runtime; current SDKs renamed it to
49
+ * `formatAgentEnvelope` with the same envelope fields. Prefer the current
50
+ * name and fall back for older hosts; if neither exists, pass the raw body
51
+ * through so dispatch still works.
52
+ */
53
+ function formatInboundEnvelope(pluginRuntime, params) {
54
+ const reply = pluginRuntime.channel.reply;
55
+ const format = reply.formatAgentEnvelope ?? reply.formatInboundEnvelope;
56
+ if (typeof format !== "function") {
57
+ return params.body;
58
+ }
59
+ return format(params);
60
+ }
61
+ /**
62
+ * Poll the msg9 inbox until the abort signal fires.
63
+ *
64
+ * Each sweep drains `agent.unread()` fully. Every message is deduped by
65
+ * message_id, allowFrom-filtered (non-listed senders are marked read and
66
+ * skipped), then marked processed BEFORE dispatch so the agent is driven
67
+ * at-most-once even if dispatch or delivery fails.
68
+ */
69
+ export async function startGateway(ctx) {
70
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
71
+ if (!account.address || !account.apiKey) {
72
+ throw new Error("msg9 not configured (missing address/apiKey)");
73
+ }
74
+ let aborted = false;
75
+ abortSignal.addEventListener("abort", () => {
76
+ aborted = true;
77
+ }, { once: true });
78
+ const agent = createMsg9Agent(account);
79
+ const seenMessageIds = new Set();
80
+ onReady?.({});
81
+ while (!aborted) {
82
+ try {
83
+ for await (const message of agent.unread()) {
84
+ if (aborted)
85
+ break;
86
+ const messageId = message.message_id ?? message.id;
87
+ if (seenMessageIds.has(messageId)) {
88
+ continue;
89
+ }
90
+ if (seenMessageIds.size >= SEEN_MESSAGE_IDS_MAX) {
91
+ seenMessageIds.clear();
92
+ }
93
+ seenMessageIds.add(messageId);
94
+ const from = message.from_address;
95
+ // allowFrom check — blocked senders stay visible in the owner's
96
+ // unprocessed folder but never reach the agent.
97
+ if (account.allowFrom && account.allowFrom.length > 0 && !account.allowFrom.includes(from)) {
98
+ log?.info(`[msg9:${account.accountId}] Ignoring message from unlisted ${from}`);
99
+ await agent.markRead(messageId);
100
+ continue;
101
+ }
102
+ log?.info(`[msg9:${account.accountId}] Message from ${from} msg=${messageId}: ${(message.body?.text ?? "").slice(0, 100)}`);
103
+ // Mark processed first: implies read, is idempotent, and matches the
104
+ // server-side auto-close that happens when a reply goes out.
105
+ await agent.markProcessed(messageId);
106
+ await dispatchMessage({ message, messageId, from, account, cfg, agent, log });
107
+ }
108
+ }
109
+ catch (err) {
110
+ log?.error(`[msg9:${account.accountId}] Poll sweep failed: ${err}`);
111
+ onError?.(err instanceof Error ? err : new Error(String(err)));
112
+ }
113
+ await sleepAbortable(account.pollIntervalMs, abortSignal);
114
+ }
115
+ // Do not return until the host stops this account.
116
+ if (abortSignal.aborted) {
117
+ return;
118
+ }
119
+ await new Promise((resolve) => {
120
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
121
+ });
122
+ }
123
+ async function dispatchMessage(params) {
124
+ const { message, messageId, from, account, cfg, agent, log } = params;
125
+ const pluginRuntime = getMsg9Runtime();
126
+ pluginRuntime.channel.activity.record({
127
+ channel: "msg9",
128
+ accountId: account.accountId,
129
+ direction: "inbound",
130
+ });
131
+ const route = pluginRuntime.channel.routing.resolveAgentRoute({
132
+ cfg,
133
+ channel: "msg9",
134
+ accountId: account.accountId,
135
+ peer: {
136
+ kind: "direct",
137
+ id: from,
138
+ },
139
+ });
140
+ const bodyText = message.body?.text ?? "";
141
+ const timestamp = Date.parse(message.created_at);
142
+ const body = formatInboundEnvelope(pluginRuntime, {
143
+ channel: "Msg9",
144
+ from,
145
+ timestamp,
146
+ body: bodyText,
147
+ });
148
+ const commandAuthorized = resolveInboundCommandAuthorization({
149
+ pluginRuntime,
150
+ cfg,
151
+ allowFrom: account.allowFrom,
152
+ peerId: from,
153
+ });
154
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
155
+ Body: body,
156
+ RawBody: bodyText,
157
+ From: `msg9:${from}`,
158
+ To: account.address,
159
+ SessionKey: route.sessionKey,
160
+ AccountId: route.accountId,
161
+ ChatType: "direct",
162
+ SenderId: from,
163
+ SenderName: from,
164
+ Provider: "msg9",
165
+ Surface: "msg9",
166
+ MessageSid: messageId,
167
+ Timestamp: timestamp,
168
+ CommandAuthorized: commandAuthorized,
169
+ CommandSource: "text",
170
+ OriginatingChannel: "msg9",
171
+ OriginatingTo: account.address,
172
+ });
173
+ log?.info(`[msg9:${account.accountId}] ctxPayload: From=msg9:${from}, To=${account.address}, SessionKey=${route.sessionKey}, MessageSid=${messageId}`);
174
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
175
+ try {
176
+ await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
177
+ ctx: ctxPayload,
178
+ cfg,
179
+ dispatcherOptions: {
180
+ responsePrefix: messagesConfig.responsePrefix,
181
+ deliver: async (payload) => {
182
+ if (!payload.text)
183
+ return;
184
+ await agent.send({
185
+ to: from,
186
+ body: { text: payload.text },
187
+ reply_to: messageId,
188
+ });
189
+ pluginRuntime.channel.activity.record({
190
+ channel: "msg9",
191
+ accountId: account.accountId,
192
+ direction: "outbound",
193
+ });
194
+ },
195
+ onError: async (err) => {
196
+ log?.error(`[msg9:${account.accountId}] Dispatch error: ${err}`);
197
+ },
198
+ },
199
+ replyOptions: {},
200
+ });
201
+ }
202
+ catch (err) {
203
+ log?.error(`[msg9:${account.accountId}] Message processing failed: ${err}`);
204
+ }
205
+ }
@@ -0,0 +1,5 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
2
+ export declare const setMsg9Runtime: (next: PluginRuntime) => void;
3
+ export declare const clearMsg9Runtime: () => void;
4
+ export declare const tryGetMsg9Runtime: () => PluginRuntime | null;
5
+ export declare const getMsg9Runtime: () => PluginRuntime;
@@ -0,0 +1,6 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ const runtimeStore = createPluginRuntimeStore("msg9 runtime not initialized");
3
+ export const setMsg9Runtime = runtimeStore.setRuntime;
4
+ export const clearMsg9Runtime = runtimeStore.clearRuntime;
5
+ export const tryGetMsg9Runtime = runtimeStore.tryGetRuntime;
6
+ export const getMsg9Runtime = runtimeStore.getRuntime;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * msg9 account configuration (raw config values)
3
+ */
4
+ export interface Msg9AccountConfig {
5
+ enabled?: boolean;
6
+ name?: string;
7
+ /** msg9 agent address, e.g. my-project@kimi.ice.msg9.io */
8
+ address?: string;
9
+ /** Agent API key (msg9_sk_...); treat as a secret */
10
+ apiKey?: string;
11
+ /** msg9 API base URL (defaults to https://api.msg9.io) */
12
+ baseUrl?: string;
13
+ /** Inbox poll interval in milliseconds (default 3000) */
14
+ pollIntervalMs?: number;
15
+ /** Whitelist of allowed sender addresses; empty/undefined allows all */
16
+ allowFrom?: string[];
17
+ }
18
+ /**
19
+ * Resolved msg9 account (ready to use)
20
+ */
21
+ export interface ResolvedMsg9Account {
22
+ accountId: string;
23
+ name?: string;
24
+ enabled: boolean;
25
+ address: string;
26
+ apiKey: string;
27
+ baseUrl?: string;
28
+ pollIntervalMs: number;
29
+ allowFrom?: string[];
30
+ config: Msg9AccountConfig;
31
+ }
32
+ /**
33
+ * msg9 channel config section (top-level account + named sub-accounts)
34
+ */
35
+ export interface Msg9ChannelConfig extends Msg9AccountConfig {
36
+ accounts?: Record<string, Msg9AccountConfig>;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ {
2
+ "id": "openclaw-msg9",
3
+ "name": "msg9",
4
+ "description": "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
5
+ "version": "0.1.0",
6
+ "channels": ["msg9"],
7
+ "channelEnvVars": {
8
+ "msg9": [
9
+ "MSG9_ADDRESS",
10
+ "MSG9_API_KEY",
11
+ "MSG9_BASE_URL"
12
+ ]
13
+ },
14
+ "configSchema": {
15
+ "type": "object",
16
+ "properties": {},
17
+ "additionalProperties": false
18
+ },
19
+ "channelConfigs": {
20
+ "msg9": {
21
+ "label": "msg9",
22
+ "description": "msg9.io agent inbox settings",
23
+ "schema": {
24
+ "$schema": "http://json-schema.org/draft-07/schema#",
25
+ "type": "object",
26
+ "properties": {
27
+ "enabled": {
28
+ "type": "boolean",
29
+ "default": true
30
+ },
31
+ "name": {
32
+ "type": "string",
33
+ "description": "Display name for this account"
34
+ },
35
+ "address": {
36
+ "type": "string",
37
+ "description": "msg9 agent address, e.g. my-project@kimi.ice.msg9.io"
38
+ },
39
+ "apiKey": {
40
+ "type": "string",
41
+ "description": "Agent API key (msg9_sk_...); treat as a secret"
42
+ },
43
+ "baseUrl": {
44
+ "type": "string",
45
+ "description": "msg9 API base URL (defaults to https://api.msg9.io)"
46
+ },
47
+ "pollIntervalMs": {
48
+ "type": "integer",
49
+ "minimum": 250,
50
+ "description": "Inbox poll interval in milliseconds (default 3000)"
51
+ },
52
+ "allowFrom": {
53
+ "type": "array",
54
+ "items": { "type": "string" },
55
+ "description": "Allowed sender addresses (full msg9 addresses). Configure this for trusted senders before enabling text commands. Empty = allow all."
56
+ },
57
+ "accounts": {
58
+ "type": "object",
59
+ "description": "Additional msg9 accounts",
60
+ "additionalProperties": {
61
+ "type": "object",
62
+ "properties": {
63
+ "enabled": { "type": "boolean" },
64
+ "name": { "type": "string" },
65
+ "address": {
66
+ "type": "string",
67
+ "description": "msg9 agent address, e.g. my-project@kimi.ice.msg9.io"
68
+ },
69
+ "apiKey": {
70
+ "type": "string",
71
+ "description": "Agent API key (msg9_sk_...); treat as a secret"
72
+ },
73
+ "baseUrl": {
74
+ "type": "string",
75
+ "description": "msg9 API base URL (defaults to https://api.msg9.io)"
76
+ },
77
+ "pollIntervalMs": {
78
+ "type": "integer",
79
+ "minimum": 250,
80
+ "description": "Inbox poll interval in milliseconds (default 3000)"
81
+ },
82
+ "allowFrom": {
83
+ "type": "array",
84
+ "items": { "type": "string" },
85
+ "description": "Allowed sender addresses (full msg9 addresses). Empty = allow all."
86
+ }
87
+ },
88
+ "additionalProperties": false
89
+ }
90
+ }
91
+ },
92
+ "additionalProperties": false
93
+ },
94
+ "uiHints": {
95
+ "address": {
96
+ "label": "Agent address",
97
+ "placeholder": "my-project@kimi.ice.msg9.io"
98
+ },
99
+ "apiKey": {
100
+ "label": "API key",
101
+ "sensitive": true
102
+ },
103
+ "baseUrl": {
104
+ "label": "API base URL",
105
+ "placeholder": "https://api.msg9.io"
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "openclaw-msg9",
3
+ "displayName": "OpenClaw msg9",
4
+ "version": "0.1.0",
5
+ "description": "OpenClaw msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel (poll-based inbound, reply_to thread closing).",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "openclaw.plugin.json"
12
+ ],
13
+ "author": "msg9.io",
14
+ "license": "MIT",
15
+ "openclaw": {
16
+ "extensions": [
17
+ "./dist/index.js"
18
+ ],
19
+ "setupEntry": "./dist/setup-entry.js",
20
+ "compat": {
21
+ "pluginApi": ">=2026.3.23-1"
22
+ },
23
+ "build": {
24
+ "openclawVersion": "2026.4.26",
25
+ "pluginSdkVersion": "2026.4.26"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "pack:dry-run": "npm pack --dry-run"
33
+ },
34
+ "dependencies": {
35
+ "msg9-io": "^0.10.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.0.0",
39
+ "openclaw": "^2026.4.26",
40
+ "typescript": "^5.0.0",
41
+ "vitest": "^2.1.9"
42
+ }
43
+ }