opencode-gateway 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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/binding/execution.d.ts +24 -0
  4. package/dist/binding/execution.js +1 -0
  5. package/dist/binding/gateway.d.ts +71 -0
  6. package/dist/binding/gateway.js +1 -0
  7. package/dist/binding/index.d.ts +15 -0
  8. package/dist/binding/index.js +4 -0
  9. package/dist/binding/opencode.d.ts +123 -0
  10. package/dist/binding/opencode.js +1 -0
  11. package/dist/cli/args.d.ts +9 -0
  12. package/dist/cli/args.js +53 -0
  13. package/dist/cli/doctor.d.ts +6 -0
  14. package/dist/cli/doctor.js +59 -0
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +35 -0
  17. package/dist/cli/opencode-config.d.ts +10 -0
  18. package/dist/cli/opencode-config.js +62 -0
  19. package/dist/cli/paths.d.ts +7 -0
  20. package/dist/cli/paths.js +22 -0
  21. package/dist/cli/templates.d.ts +1 -0
  22. package/dist/cli/templates.js +26 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.js +314 -0
  25. package/dist/config/cron.d.ts +7 -0
  26. package/dist/config/cron.js +52 -0
  27. package/dist/config/gateway.d.ts +26 -0
  28. package/dist/config/gateway.js +142 -0
  29. package/dist/config/paths.d.ts +10 -0
  30. package/dist/config/paths.js +33 -0
  31. package/dist/config/telegram.d.ts +13 -0
  32. package/dist/config/telegram.js +91 -0
  33. package/dist/cron/runtime.d.ts +40 -0
  34. package/dist/cron/runtime.js +237 -0
  35. package/dist/delivery/telegram.d.ts +16 -0
  36. package/dist/delivery/telegram.js +75 -0
  37. package/dist/delivery/text.d.ts +21 -0
  38. package/dist/delivery/text.js +175 -0
  39. package/dist/gateway.d.ts +33 -0
  40. package/dist/gateway.js +105 -0
  41. package/dist/host/file-sender.d.ts +16 -0
  42. package/dist/host/file-sender.js +59 -0
  43. package/dist/host/noop.d.ts +4 -0
  44. package/dist/host/noop.js +14 -0
  45. package/dist/host/transport.d.ts +9 -0
  46. package/dist/host/transport.js +35 -0
  47. package/dist/index.d.ts +7 -0
  48. package/dist/index.js +52 -0
  49. package/dist/mailbox/router.d.ts +7 -0
  50. package/dist/mailbox/router.js +16 -0
  51. package/dist/media/mime.d.ts +2 -0
  52. package/dist/media/mime.js +45 -0
  53. package/dist/opencode/adapter.d.ts +19 -0
  54. package/dist/opencode/adapter.js +291 -0
  55. package/dist/opencode/driver-hub.d.ts +15 -0
  56. package/dist/opencode/driver-hub.js +82 -0
  57. package/dist/opencode/event-normalize.d.ts +48 -0
  58. package/dist/opencode/event-normalize.js +48 -0
  59. package/dist/opencode/event-stream.d.ts +23 -0
  60. package/dist/opencode/event-stream.js +65 -0
  61. package/dist/opencode/events.d.ts +2 -0
  62. package/dist/opencode/events.js +1 -0
  63. package/dist/questions/client.d.ts +5 -0
  64. package/dist/questions/client.js +36 -0
  65. package/dist/questions/format.d.ts +3 -0
  66. package/dist/questions/format.js +36 -0
  67. package/dist/questions/normalize.d.ts +10 -0
  68. package/dist/questions/normalize.js +45 -0
  69. package/dist/questions/parser.d.ts +11 -0
  70. package/dist/questions/parser.js +96 -0
  71. package/dist/questions/runtime.d.ts +53 -0
  72. package/dist/questions/runtime.js +195 -0
  73. package/dist/questions/types.d.ts +22 -0
  74. package/dist/questions/types.js +1 -0
  75. package/dist/runtime/attachments.d.ts +3 -0
  76. package/dist/runtime/attachments.js +12 -0
  77. package/dist/runtime/executor.d.ts +24 -0
  78. package/dist/runtime/executor.js +188 -0
  79. package/dist/runtime/mailbox.d.ts +25 -0
  80. package/dist/runtime/mailbox.js +112 -0
  81. package/dist/runtime/opencode-runner.d.ts +26 -0
  82. package/dist/runtime/opencode-runner.js +79 -0
  83. package/dist/session/context.d.ts +10 -0
  84. package/dist/session/context.js +44 -0
  85. package/dist/session/conversation-key.d.ts +3 -0
  86. package/dist/session/conversation-key.js +3 -0
  87. package/dist/session/switcher.d.ts +25 -0
  88. package/dist/session/switcher.js +59 -0
  89. package/dist/store/migrations.d.ts +2 -0
  90. package/dist/store/migrations.js +183 -0
  91. package/dist/store/sqlite.d.ts +127 -0
  92. package/dist/store/sqlite.js +678 -0
  93. package/dist/telegram/client.d.ts +35 -0
  94. package/dist/telegram/client.js +179 -0
  95. package/dist/telegram/media.d.ts +13 -0
  96. package/dist/telegram/media.js +65 -0
  97. package/dist/telegram/normalize.d.ts +47 -0
  98. package/dist/telegram/normalize.js +119 -0
  99. package/dist/telegram/poller.d.ts +29 -0
  100. package/dist/telegram/poller.js +97 -0
  101. package/dist/telegram/runtime.d.ts +51 -0
  102. package/dist/telegram/runtime.js +133 -0
  103. package/dist/telegram/state.d.ts +36 -0
  104. package/dist/telegram/state.js +128 -0
  105. package/dist/telegram/types.d.ts +80 -0
  106. package/dist/telegram/types.js +1 -0
  107. package/dist/tools/channel-new-session.d.ts +4 -0
  108. package/dist/tools/channel-new-session.js +27 -0
  109. package/dist/tools/channel-send-file.d.ts +9 -0
  110. package/dist/tools/channel-send-file.js +27 -0
  111. package/dist/tools/channel-target.d.ts +7 -0
  112. package/dist/tools/channel-target.js +28 -0
  113. package/dist/tools/cron-list.d.ts +3 -0
  114. package/dist/tools/cron-list.js +34 -0
  115. package/dist/tools/cron-remove.d.ts +3 -0
  116. package/dist/tools/cron-remove.js +12 -0
  117. package/dist/tools/cron-run.d.ts +3 -0
  118. package/dist/tools/cron-run.js +20 -0
  119. package/dist/tools/cron-upsert.d.ts +3 -0
  120. package/dist/tools/cron-upsert.js +37 -0
  121. package/dist/tools/gateway-dispatch-cron.d.ts +3 -0
  122. package/dist/tools/gateway-dispatch-cron.js +33 -0
  123. package/dist/tools/gateway-status.d.ts +3 -0
  124. package/dist/tools/gateway-status.js +25 -0
  125. package/dist/tools/telegram-send-test.d.ts +3 -0
  126. package/dist/tools/telegram-send-test.js +26 -0
  127. package/dist/tools/telegram-status.d.ts +3 -0
  128. package/dist/tools/telegram-status.js +49 -0
  129. package/dist/tools/time.d.ts +3 -0
  130. package/dist/tools/time.js +25 -0
  131. package/dist/utils/error.d.ts +1 -0
  132. package/dist/utils/error.js +57 -0
  133. package/generated/wasm/pkg/opencode_gateway_ffi.d.ts +23 -0
  134. package/generated/wasm/pkg/opencode_gateway_ffi.js +574 -0
  135. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  136. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm.d.ts +22 -0
  137. package/package.json +61 -0
@@ -0,0 +1,45 @@
1
+ export function normalizeQuestionEvent(event) {
2
+ if (isQuestionAskedEvent(event)) {
3
+ return {
4
+ kind: "asked",
5
+ request: {
6
+ requestId: event.properties.id,
7
+ sessionId: event.properties.sessionID,
8
+ questions: event.properties.questions.map((question) => ({
9
+ header: question.header,
10
+ question: question.question,
11
+ options: question.options.map((option) => ({
12
+ label: option.label,
13
+ description: option.description,
14
+ })),
15
+ multiple: question.multiple === true,
16
+ custom: question.custom !== false,
17
+ })),
18
+ },
19
+ };
20
+ }
21
+ if (isQuestionResolvedEvent(event)) {
22
+ return {
23
+ kind: "resolved",
24
+ requestId: event.properties.requestID,
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+ function isQuestionAskedEvent(event) {
30
+ if (event.type !== "question.asked" || typeof event.properties !== "object" || event.properties === null) {
31
+ return false;
32
+ }
33
+ const properties = event.properties;
34
+ return (typeof properties.id === "string" &&
35
+ typeof properties.sessionID === "string" &&
36
+ Array.isArray(properties.questions));
37
+ }
38
+ function isQuestionResolvedEvent(event) {
39
+ if ((event.type !== "question.replied" && event.type !== "question.rejected") ||
40
+ typeof event.properties !== "object" ||
41
+ event.properties === null) {
42
+ return false;
43
+ }
44
+ return typeof event.properties.requestID === "string";
45
+ }
@@ -0,0 +1,11 @@
1
+ import type { GatewayQuestionRequest } from "./types";
2
+ export type ParsedQuestionReply = {
3
+ kind: "reply";
4
+ answers: string[][];
5
+ } | {
6
+ kind: "reject";
7
+ } | {
8
+ kind: "invalid";
9
+ message: string;
10
+ };
11
+ export declare function parseQuestionReply(request: GatewayQuestionRequest, text: string | null): ParsedQuestionReply;
@@ -0,0 +1,96 @@
1
+ const CANCEL_WORDS = new Set(["/cancel", "cancel", "/reject", "reject"]);
2
+ export function parseQuestionReply(request, text) {
3
+ if (text === null) {
4
+ return {
5
+ kind: "invalid",
6
+ message: "This question currently accepts text replies only.",
7
+ };
8
+ }
9
+ const trimmed = text.trim();
10
+ if (trimmed.length === 0) {
11
+ return {
12
+ kind: "invalid",
13
+ message: "Reply text must not be empty.",
14
+ };
15
+ }
16
+ if (CANCEL_WORDS.has(trimmed.toLowerCase())) {
17
+ return {
18
+ kind: "reject",
19
+ };
20
+ }
21
+ if (request.questions.length === 1) {
22
+ const parsedAnswer = parseQuestionLine(request.questions[0], trimmed);
23
+ return parsedAnswer.kind === "invalid"
24
+ ? parsedAnswer
25
+ : {
26
+ kind: "reply",
27
+ answers: [parsedAnswer.answer],
28
+ };
29
+ }
30
+ const lines = trimmed
31
+ .split(/\r?\n/u)
32
+ .map((line) => line.trim())
33
+ .filter((line) => line.length > 0);
34
+ if (lines.length !== request.questions.length) {
35
+ return {
36
+ kind: "invalid",
37
+ message: `Expected ${request.questions.length} non-empty lines, but received ${lines.length}.`,
38
+ };
39
+ }
40
+ const answers = [];
41
+ for (const [index, question] of request.questions.entries()) {
42
+ const parsedAnswer = parseQuestionLine(question, lines[index]);
43
+ if (parsedAnswer.kind === "invalid") {
44
+ return parsedAnswer;
45
+ }
46
+ answers.push(parsedAnswer.answer);
47
+ }
48
+ return {
49
+ kind: "reply",
50
+ answers,
51
+ };
52
+ }
53
+ function parseQuestionLine(question, line) {
54
+ const rawSelections = question.multiple
55
+ ? line
56
+ .split(/[,\n]/u)
57
+ .map((token) => token.trim())
58
+ .filter((token) => token.length > 0)
59
+ : [line];
60
+ if (rawSelections.length === 0) {
61
+ return {
62
+ kind: "invalid",
63
+ message: "At least one answer is required.",
64
+ };
65
+ }
66
+ const answers = [];
67
+ for (const rawSelection of rawSelections) {
68
+ const option = resolveOptionSelection(question, rawSelection);
69
+ if (option !== null) {
70
+ answers.push(option);
71
+ continue;
72
+ }
73
+ if (!question.custom) {
74
+ return {
75
+ kind: "invalid",
76
+ message: `Answer "${rawSelection}" does not match any allowed option.`,
77
+ };
78
+ }
79
+ answers.push(rawSelection);
80
+ }
81
+ return {
82
+ kind: "answer",
83
+ answer: answers,
84
+ };
85
+ }
86
+ function resolveOptionSelection(question, selection) {
87
+ if (question.options.length === 0) {
88
+ return null;
89
+ }
90
+ const numericIndex = Number.parseInt(selection, 10);
91
+ if (Number.isSafeInteger(numericIndex) && String(numericIndex) === selection && numericIndex >= 1) {
92
+ return question.options[numericIndex - 1]?.label ?? null;
93
+ }
94
+ const normalized = selection.toLowerCase();
95
+ return question.options.find((option) => option.label.toLowerCase() === normalized)?.label ?? null;
96
+ }
@@ -0,0 +1,53 @@
1
+ import type { BindingDeliveryTarget, BindingInboundMessage, BindingLoggerHost } from "../binding";
2
+ import type { OpencodeRuntimeEvent } from "../opencode/events";
3
+ import type { GatewaySessionContext } from "../session/context";
4
+ import type { SqliteStore } from "../store/sqlite";
5
+ import type { TelegramQuestionClientLike } from "../telegram/client";
6
+ import type { TelegramNormalizedCallbackQuery } from "../telegram/normalize";
7
+ type QuestionClientLike = {
8
+ question: {
9
+ reply(input: {
10
+ requestID: string;
11
+ directory?: string;
12
+ answers?: string[][];
13
+ }, options?: {
14
+ responseStyle?: "data";
15
+ throwOnError?: boolean;
16
+ }): Promise<unknown>;
17
+ reject(input: {
18
+ requestID: string;
19
+ directory?: string;
20
+ }, options?: {
21
+ responseStyle?: "data";
22
+ throwOnError?: boolean;
23
+ }): Promise<unknown>;
24
+ };
25
+ };
26
+ export declare class GatewayQuestionRuntime {
27
+ private readonly client;
28
+ private readonly directory;
29
+ private readonly store;
30
+ private readonly sessions;
31
+ private readonly transport;
32
+ private readonly telegramClient;
33
+ private readonly logger;
34
+ constructor(client: QuestionClientLike, directory: string, store: SqliteStore, sessions: GatewaySessionContext, transport: QuestionTransportLike, telegramClient: TelegramQuestionClientLike | null, logger: BindingLoggerHost);
35
+ handleEvent(event: OpencodeRuntimeEvent): void;
36
+ tryHandleInboundMessage(message: BindingInboundMessage): Promise<boolean>;
37
+ handleTelegramCallbackQuery(query: TelegramNormalizedCallbackQuery): Promise<boolean>;
38
+ private processEvent;
39
+ private handleQuestionAsked;
40
+ private sendQuestion;
41
+ private sendPlainText;
42
+ private replyQuestion;
43
+ private rejectQuestion;
44
+ }
45
+ type QuestionTransportLike = {
46
+ sendMessage(input: {
47
+ deliveryTarget: BindingDeliveryTarget;
48
+ body: string;
49
+ }): Promise<{
50
+ errorMessage: string | null;
51
+ }>;
52
+ };
53
+ export {};
@@ -0,0 +1,195 @@
1
+ import { recordTelegramSendFailure, recordTelegramSendSuccess } from "../telegram/state";
2
+ import { formatError } from "../utils/error";
3
+ import { formatPlainTextQuestion, formatQuestionReplyError } from "./format";
4
+ import { normalizeQuestionEvent } from "./normalize";
5
+ import { parseQuestionReply } from "./parser";
6
+ export class GatewayQuestionRuntime {
7
+ client;
8
+ directory;
9
+ store;
10
+ sessions;
11
+ transport;
12
+ telegramClient;
13
+ logger;
14
+ constructor(client, directory, store, sessions, transport, telegramClient, logger) {
15
+ this.client = client;
16
+ this.directory = directory;
17
+ this.store = store;
18
+ this.sessions = sessions;
19
+ this.transport = transport;
20
+ this.telegramClient = telegramClient;
21
+ this.logger = logger;
22
+ }
23
+ handleEvent(event) {
24
+ const normalized = normalizeQuestionEvent(event);
25
+ if (normalized === null) {
26
+ return;
27
+ }
28
+ void this.processEvent(normalized).catch((error) => {
29
+ this.logger.log("warn", `question bridge failed: ${formatError(error)}`);
30
+ });
31
+ }
32
+ async tryHandleInboundMessage(message) {
33
+ const pending = this.store.getPendingQuestionForTarget(message.deliveryTarget);
34
+ if (pending === null) {
35
+ return false;
36
+ }
37
+ const parsed = parseQuestionReply(pending, message.text);
38
+ switch (parsed.kind) {
39
+ case "invalid":
40
+ await this.sendPlainText(pending.deliveryTarget, formatQuestionReplyError(pending, parsed.message));
41
+ return true;
42
+ case "reject":
43
+ await this.rejectQuestion(pending.requestId);
44
+ this.store.deletePendingQuestion(pending.requestId);
45
+ return true;
46
+ case "reply":
47
+ await this.replyQuestion(pending.requestId, parsed.answers);
48
+ this.store.deletePendingQuestion(pending.requestId);
49
+ return true;
50
+ }
51
+ }
52
+ async handleTelegramCallbackQuery(query) {
53
+ if (this.telegramClient === null) {
54
+ return false;
55
+ }
56
+ const pending = this.store.getPendingQuestionForTelegramMessage(query.deliveryTarget, query.messageId);
57
+ if (pending === null) {
58
+ await this.telegramClient.answerCallbackQuery(query.callbackQueryId, "This question is no longer pending.");
59
+ return true;
60
+ }
61
+ const answer = resolveCallbackAnswer(query.data, pending);
62
+ if (answer === null) {
63
+ await this.telegramClient.answerCallbackQuery(query.callbackQueryId, "This button is no longer valid.");
64
+ return true;
65
+ }
66
+ await this.replyQuestion(pending.requestId, [[answer]]);
67
+ this.store.deletePendingQuestion(pending.requestId);
68
+ await this.telegramClient.answerCallbackQuery(query.callbackQueryId, `Sent: ${answer}`);
69
+ return true;
70
+ }
71
+ async processEvent(event) {
72
+ switch (event.kind) {
73
+ case "asked":
74
+ await this.handleQuestionAsked(event.request);
75
+ return;
76
+ case "resolved":
77
+ this.store.deletePendingQuestion(event.requestId);
78
+ return;
79
+ }
80
+ }
81
+ async handleQuestionAsked(request) {
82
+ const targets = this.sessions.listReplyTargets(request.sessionId);
83
+ if (targets.length === 0) {
84
+ this.logger.log("warn", `question ${request.requestId} has no reply target for session ${request.sessionId}`);
85
+ return;
86
+ }
87
+ const deliveredTargets = [];
88
+ for (const target of targets) {
89
+ try {
90
+ deliveredTargets.push(await this.sendQuestion(target, request));
91
+ }
92
+ catch (error) {
93
+ this.logger.log("warn", `question ${request.requestId} delivery failed for ${target.channel}:${target.target}: ${formatError(error)}`);
94
+ }
95
+ }
96
+ if (deliveredTargets.length === 0) {
97
+ return;
98
+ }
99
+ this.store.replacePendingQuestion({
100
+ requestId: request.requestId,
101
+ sessionId: request.sessionId,
102
+ questions: request.questions,
103
+ targets: deliveredTargets,
104
+ recordedAtMs: Date.now(),
105
+ });
106
+ }
107
+ async sendQuestion(target, request) {
108
+ const nativeKeyboard = buildTelegramInlineKeyboard(request);
109
+ if (target.channel === "telegram" && nativeKeyboard !== null && this.telegramClient !== null) {
110
+ try {
111
+ const sent = await this.telegramClient.sendInteractiveMessage(target.target, formatTelegramNativeQuestion(request), target.topic, nativeKeyboard);
112
+ recordTelegramSendSuccess(this.store, Date.now());
113
+ return {
114
+ deliveryTarget: target,
115
+ telegramMessageId: sent.message_id,
116
+ };
117
+ }
118
+ catch (error) {
119
+ recordTelegramSendFailure(this.store, formatError(error), Date.now());
120
+ throw error;
121
+ }
122
+ }
123
+ await this.sendPlainText(target, formatPlainTextQuestion(request));
124
+ return {
125
+ deliveryTarget: target,
126
+ telegramMessageId: null,
127
+ };
128
+ }
129
+ async sendPlainText(target, body) {
130
+ const ack = await this.transport.sendMessage({
131
+ deliveryTarget: target,
132
+ body,
133
+ });
134
+ if (ack.errorMessage !== null) {
135
+ throw new Error(ack.errorMessage);
136
+ }
137
+ }
138
+ async replyQuestion(requestId, answers) {
139
+ await this.client.question.reply({
140
+ requestID: requestId,
141
+ directory: this.directory,
142
+ answers,
143
+ }, {
144
+ responseStyle: "data",
145
+ throwOnError: true,
146
+ });
147
+ }
148
+ async rejectQuestion(requestId) {
149
+ await this.client.question.reject({
150
+ requestID: requestId,
151
+ directory: this.directory,
152
+ }, {
153
+ responseStyle: "data",
154
+ throwOnError: true,
155
+ });
156
+ }
157
+ }
158
+ function buildTelegramInlineKeyboard(request) {
159
+ if (request.questions.length !== 1) {
160
+ return null;
161
+ }
162
+ const [question] = request.questions;
163
+ if (question.multiple || question.options.length === 0) {
164
+ return null;
165
+ }
166
+ return {
167
+ inline_keyboard: question.options.map((option, index) => [
168
+ {
169
+ text: option.label,
170
+ callback_data: `q:${index}`,
171
+ },
172
+ ]),
173
+ };
174
+ }
175
+ function formatTelegramNativeQuestion(request) {
176
+ const [question] = request.questions;
177
+ return [
178
+ "OpenCode needs additional input before it can continue.",
179
+ "",
180
+ `${question.header}: ${question.question}`,
181
+ "",
182
+ "Tap a button below or reply with text.",
183
+ ].join("\n");
184
+ }
185
+ function resolveCallbackAnswer(data, pending) {
186
+ if (data === null || !data.startsWith("q:") || pending.questions.length !== 1) {
187
+ return null;
188
+ }
189
+ const indexText = data.slice(2);
190
+ const index = Number.parseInt(indexText, 10);
191
+ if (!Number.isSafeInteger(index) || index < 0) {
192
+ return null;
193
+ }
194
+ return pending.questions[0]?.options[index]?.label ?? null;
195
+ }
@@ -0,0 +1,22 @@
1
+ import type { BindingDeliveryTarget } from "../binding";
2
+ export type GatewayQuestionOption = {
3
+ label: string;
4
+ description: string;
5
+ };
6
+ export type GatewayQuestionInfo = {
7
+ header: string;
8
+ question: string;
9
+ options: GatewayQuestionOption[];
10
+ multiple: boolean;
11
+ custom: boolean;
12
+ };
13
+ export type GatewayQuestionRequest = {
14
+ requestId: string;
15
+ sessionId: string;
16
+ questions: GatewayQuestionInfo[];
17
+ };
18
+ export type PendingQuestionRecord = GatewayQuestionRequest & {
19
+ deliveryTarget: BindingDeliveryTarget;
20
+ telegramMessageId: number | null;
21
+ createdAtMs: number;
22
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { BindingLoggerHost } from "../binding";
2
+ import type { MailboxEntryRecord } from "../store/sqlite";
3
+ export declare function deleteInboundAttachmentFiles(entries: Pick<MailboxEntryRecord, "attachments">[], logger: Pick<BindingLoggerHost, "log">): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { rm } from "node:fs/promises";
2
+ export async function deleteInboundAttachmentFiles(entries, logger) {
3
+ const paths = new Set(entries.flatMap((entry) => entry.attachments.map((attachment) => attachment.localPath)));
4
+ await Promise.all([...paths].map(async (path) => {
5
+ try {
6
+ await rm(path, { force: true });
7
+ }
8
+ catch (error) {
9
+ logger.log("warn", `failed to remove cached inbound attachment ${path}: ${String(error)}`);
10
+ }
11
+ }));
12
+ }
@@ -0,0 +1,24 @@
1
+ import type { BindingCronJobSpec, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
2
+ import type { GatewayTextDelivery } from "../delivery/text";
3
+ import type { OpencodeSdkAdapter } from "../opencode/adapter";
4
+ import type { OpencodeEventHub } from "../opencode/events";
5
+ import type { MailboxEntryRecord, SqliteStore } from "../store/sqlite";
6
+ export declare class GatewayExecutor {
7
+ private readonly module;
8
+ private readonly store;
9
+ private readonly opencode;
10
+ private readonly events;
11
+ private readonly delivery;
12
+ private readonly logger;
13
+ constructor(module: GatewayBindingModule, store: SqliteStore, opencode: GatewayOpencodeRuntimeLike, events: OpencodeEventHub, delivery: GatewayTextDeliveryLike, logger: BindingLoggerHost);
14
+ prepareInboundMessage(message: BindingInboundMessage): BindingPreparedExecution;
15
+ handleInboundMessage(message: BindingInboundMessage): Promise<BindingRuntimeReport>;
16
+ executeMailboxEntries(entries: MailboxEntryRecord[]): Promise<BindingRuntimeReport>;
17
+ dispatchCronJob(job: BindingCronJobSpec): Promise<BindingRuntimeReport>;
18
+ private executePreparedEntries;
19
+ private executeDriver;
20
+ }
21
+ export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "executeMailboxEntries" | "prepareInboundMessage">;
22
+ type GatewayTextDeliveryLike = Pick<GatewayTextDelivery, "openMany">;
23
+ type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
24
+ export {};
@@ -0,0 +1,188 @@
1
+ import { runOpencodeDriver } from "./opencode-runner";
2
+ export class GatewayExecutor {
3
+ module;
4
+ store;
5
+ opencode;
6
+ events;
7
+ delivery;
8
+ logger;
9
+ constructor(module, store, opencode, events, delivery, logger) {
10
+ this.module = module;
11
+ this.store = store;
12
+ this.opencode = opencode;
13
+ this.events = events;
14
+ this.delivery = delivery;
15
+ this.logger = logger;
16
+ }
17
+ prepareInboundMessage(message) {
18
+ return this.module.prepareInboundExecution(message);
19
+ }
20
+ async handleInboundMessage(message) {
21
+ const prepared = this.prepareInboundMessage(message);
22
+ const syntheticEntry = {
23
+ id: Date.now(),
24
+ mailboxKey: prepared.conversationKey,
25
+ sourceKind: "direct_runtime",
26
+ externalId: `direct:${Date.now()}`,
27
+ sender: normalizeRequiredField(message.sender, "message sender"),
28
+ text: message.text,
29
+ attachments: withAttachmentOrdinals(message.attachments),
30
+ replyChannel: message.deliveryTarget.channel,
31
+ replyTarget: message.deliveryTarget.target,
32
+ replyTopic: message.deliveryTarget.topic,
33
+ createdAtMs: Date.now(),
34
+ };
35
+ return await this.executeMailboxEntries([syntheticEntry]);
36
+ }
37
+ async executeMailboxEntries(entries) {
38
+ if (entries.length === 0) {
39
+ throw new Error("mailbox execution requires at least one entry");
40
+ }
41
+ const preparedEntries = entries.map((entry) => {
42
+ const message = mailboxEntryToInboundMessage(entry);
43
+ return {
44
+ entry,
45
+ message,
46
+ prepared: this.prepareInboundMessage(message),
47
+ };
48
+ });
49
+ const conversationKey = preparedEntries[0].prepared.conversationKey;
50
+ if (preparedEntries.some((entry) => entry.prepared.conversationKey !== conversationKey)) {
51
+ throw new Error("mailbox batch contains mixed conversation keys");
52
+ }
53
+ const recordedAtMs = Date.now();
54
+ this.logger.log("info", "handling inbound gateway message");
55
+ this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
56
+ entryIds: preparedEntries.map((entry) => entry.entry.id),
57
+ count: preparedEntries.length,
58
+ }));
59
+ for (const entry of preparedEntries) {
60
+ this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
61
+ deliveryTarget: entry.prepared.replyTarget,
62
+ sender: entry.message.sender,
63
+ text: entry.message.text,
64
+ attachments: entry.message.attachments,
65
+ }));
66
+ }
67
+ return await this.executePreparedEntries(preparedEntries, recordedAtMs);
68
+ }
69
+ async dispatchCronJob(job) {
70
+ const prepared = this.module.prepareCronExecution(job);
71
+ const id = normalizeRequiredField(job.id, "cron job id");
72
+ const schedule = normalizeRequiredField(job.schedule, "cron schedule");
73
+ const recordedAtMs = Date.now();
74
+ this.logger.log("info", "dispatching cron gateway job");
75
+ this.store.appendJournal(createJournalEntry("cron_dispatch", recordedAtMs, prepared.conversationKey, {
76
+ id,
77
+ schedule,
78
+ promptParts: prepared.promptParts,
79
+ deliveryChannel: prepared.replyTarget?.channel ?? null,
80
+ deliveryTarget: prepared.replyTarget?.target ?? null,
81
+ deliveryTopic: prepared.replyTarget?.topic ?? null,
82
+ }));
83
+ return await this.executePreparedEntries([
84
+ {
85
+ entry: null,
86
+ message: null,
87
+ prepared,
88
+ },
89
+ ], recordedAtMs);
90
+ }
91
+ async executePreparedEntries(entries, recordedAtMs) {
92
+ const conversationKey = entries[0].prepared.conversationKey;
93
+ const persistedSessionId = this.store.getSessionBinding(conversationKey);
94
+ const replyTargets = dedupeReplyTargets(entries.flatMap((entry) => (entry.prepared.replyTarget === null ? [] : [entry.prepared.replyTarget])));
95
+ const [deliverySession] = replyTargets.length === 0 ? [null] : await this.delivery.openMany(replyTargets, "auto");
96
+ const promptResult = await this.executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets);
97
+ this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, promptResult.sessionId, recordedAtMs);
98
+ let delivered = false;
99
+ if (deliverySession !== null) {
100
+ delivered = await deliverySession.finish(promptResult.finalText);
101
+ if (promptResult.finalText !== null) {
102
+ this.store.appendJournal(createJournalEntry("delivery", recordedAtMs, conversationKey, {
103
+ deliveryTargets: replyTargets,
104
+ body: promptResult.finalText,
105
+ }));
106
+ }
107
+ }
108
+ return {
109
+ conversationKey,
110
+ responseText: promptResult.responseText,
111
+ delivered,
112
+ recordedAtMs: BigInt(recordedAtMs),
113
+ };
114
+ }
115
+ async executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets) {
116
+ return await runOpencodeDriver({
117
+ module: this.module,
118
+ opencode: this.opencode,
119
+ events: this.events,
120
+ conversationKey: entries[0].prepared.conversationKey,
121
+ persistedSessionId,
122
+ deliverySession,
123
+ prompts: entries.map((entry, index) => ({
124
+ promptKey: createPromptKey(entry, recordedAtMs, index),
125
+ parts: entry.prepared.promptParts,
126
+ })),
127
+ onSessionAvailable: async (sessionId) => {
128
+ this.store.replaceSessionReplyTargets({
129
+ sessionId,
130
+ conversationKey: entries[0].prepared.conversationKey,
131
+ targets: replyTargets,
132
+ recordedAtMs,
133
+ });
134
+ },
135
+ });
136
+ }
137
+ }
138
+ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
139
+ return {
140
+ kind,
141
+ recordedAtMs,
142
+ conversationKey,
143
+ payload,
144
+ };
145
+ }
146
+ function mailboxEntryToInboundMessage(entry) {
147
+ return {
148
+ deliveryTarget: {
149
+ channel: normalizeRequiredField(entry.replyChannel ?? "", "mailbox reply channel"),
150
+ target: normalizeRequiredField(entry.replyTarget ?? "", "mailbox reply target"),
151
+ topic: entry.replyTopic,
152
+ },
153
+ sender: entry.sender,
154
+ text: entry.text,
155
+ attachments: entry.attachments,
156
+ mailboxKey: entry.mailboxKey,
157
+ };
158
+ }
159
+ function dedupeReplyTargets(targets) {
160
+ const seen = new Set();
161
+ return targets.filter((target) => {
162
+ const key = `${target.channel}:${target.target}:${target.topic ?? ""}`;
163
+ if (seen.has(key)) {
164
+ return false;
165
+ }
166
+ seen.add(key);
167
+ return true;
168
+ });
169
+ }
170
+ function normalizeRequiredField(value, field) {
171
+ const trimmed = value.trim();
172
+ if (trimmed.length === 0) {
173
+ throw new Error(`${field} must not be empty`);
174
+ }
175
+ return trimmed;
176
+ }
177
+ function createPromptKey(entry, recordedAtMs, index) {
178
+ if (entry.entry !== null) {
179
+ return `mailbox:${entry.entry.id}:${recordedAtMs}`;
180
+ }
181
+ return `synthetic:${recordedAtMs}:${index}`;
182
+ }
183
+ function withAttachmentOrdinals(messageAttachments) {
184
+ return messageAttachments.map((attachment, ordinal) => ({
185
+ ...attachment,
186
+ ordinal,
187
+ }));
188
+ }
@@ -0,0 +1,25 @@
1
+ import type { BindingInboundMessage, BindingLoggerHost } from "../binding";
2
+ import type { GatewayMailboxConfig } from "../config/gateway";
3
+ import type { GatewayQuestionRuntime } from "../questions/runtime";
4
+ import type { SqliteStore } from "../store/sqlite";
5
+ import type { GatewayExecutor } from "./executor";
6
+ export declare class GatewayMailboxRuntime {
7
+ private readonly executor;
8
+ private readonly store;
9
+ private readonly logger;
10
+ private readonly config;
11
+ private readonly questions;
12
+ private readonly activeMailboxes;
13
+ private readonly scheduledMailboxes;
14
+ constructor(executor: GatewayExecutorLike, store: SqliteStore, logger: BindingLoggerHost, config: GatewayMailboxConfig, questions: GatewayQuestionRuntimeLike);
15
+ start(): void;
16
+ enqueueInboundMessage(message: BindingInboundMessage, sourceKind: string, externalId: string): Promise<void>;
17
+ private scheduleAfterEnqueue;
18
+ private scheduleImmediate;
19
+ private scheduleRetry;
20
+ private schedule;
21
+ private processMailbox;
22
+ }
23
+ type GatewayExecutorLike = Pick<GatewayExecutor, "executeMailboxEntries" | "prepareInboundMessage">;
24
+ type GatewayQuestionRuntimeLike = Pick<GatewayQuestionRuntime, "tryHandleInboundMessage">;
25
+ export {};