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,40 @@
1
+ import type { BindingLoggerHost, BindingRuntimeReport, GatewayContract } from "../binding";
2
+ import type { CronConfig } from "../config/cron";
3
+ import type { GatewayExecutorLike } from "../runtime/executor";
4
+ import type { CronJobRecord, SqliteStore } from "../store/sqlite";
5
+ export type UpsertCronJobInput = {
6
+ id: string;
7
+ schedule: string;
8
+ prompt: string;
9
+ enabled: boolean;
10
+ deliveryChannel: string | null;
11
+ deliveryTarget: string | null;
12
+ deliveryTopic: string | null;
13
+ };
14
+ export declare class GatewayCronRuntime {
15
+ private readonly executor;
16
+ private readonly contract;
17
+ private readonly store;
18
+ private readonly logger;
19
+ private readonly config;
20
+ private readonly effectiveTimeZone;
21
+ private readonly runningJobIds;
22
+ private running;
23
+ constructor(executor: GatewayExecutorLike, contract: GatewayContract, store: SqliteStore, logger: BindingLoggerHost, config: CronConfig, effectiveTimeZone: string);
24
+ isEnabled(): boolean;
25
+ isRunning(): boolean;
26
+ runningJobs(): number;
27
+ timeZone(): string;
28
+ start(): void;
29
+ listJobs(): CronJobRecord[];
30
+ upsertJob(input: UpsertCronJobInput): CronJobRecord;
31
+ removeJob(id: string): boolean;
32
+ runNow(id: string): Promise<BindingRuntimeReport>;
33
+ private runLoop;
34
+ reconcileOnce(nowMs?: number): Promise<void>;
35
+ tickOnce(nowMs?: number): Promise<void>;
36
+ private executeJob;
37
+ private requireJob;
38
+ private rebaseJobs;
39
+ private readStoredEffectiveTimeZone;
40
+ }
@@ -0,0 +1,237 @@
1
+ import { formatError } from "../utils/error";
2
+ const CRON_EFFECTIVE_TIME_ZONE_KEY = "cron.effective_timezone";
3
+ const LEGACY_CRON_TIME_ZONE = "UTC";
4
+ export class GatewayCronRuntime {
5
+ executor;
6
+ contract;
7
+ store;
8
+ logger;
9
+ config;
10
+ effectiveTimeZone;
11
+ runningJobIds = new Set();
12
+ running = false;
13
+ constructor(executor, contract, store, logger, config, effectiveTimeZone) {
14
+ this.executor = executor;
15
+ this.contract = contract;
16
+ this.store = store;
17
+ this.logger = logger;
18
+ this.config = config;
19
+ this.effectiveTimeZone = effectiveTimeZone;
20
+ }
21
+ isEnabled() {
22
+ return this.config.enabled;
23
+ }
24
+ isRunning() {
25
+ return this.running;
26
+ }
27
+ runningJobs() {
28
+ return this.runningJobIds.size;
29
+ }
30
+ timeZone() {
31
+ return this.effectiveTimeZone;
32
+ }
33
+ start() {
34
+ if (!this.config.enabled || this.running) {
35
+ return;
36
+ }
37
+ this.running = true;
38
+ void this.runLoop().finally(() => {
39
+ this.running = false;
40
+ });
41
+ }
42
+ listJobs() {
43
+ return this.store.listCronJobs();
44
+ }
45
+ upsertJob(input) {
46
+ const normalized = normalizeUpsertInput(input);
47
+ const recordedAtMs = Date.now();
48
+ const nextRunAtMs = computeNextRunAt(this.contract, normalized, recordedAtMs, this.effectiveTimeZone);
49
+ this.store.upsertCronJob({
50
+ ...normalized,
51
+ nextRunAtMs,
52
+ recordedAtMs,
53
+ });
54
+ return this.requireJob(normalized.id);
55
+ }
56
+ removeJob(id) {
57
+ return this.store.removeCronJob(normalizeId(id));
58
+ }
59
+ async runNow(id) {
60
+ const job = this.requireJob(normalizeId(id));
61
+ if (this.runningJobIds.has(job.id)) {
62
+ throw new Error(`cron job is already running: ${job.id}`);
63
+ }
64
+ this.runningJobIds.add(job.id);
65
+ try {
66
+ return await this.executeJob(job, Date.now(), null);
67
+ }
68
+ finally {
69
+ this.runningJobIds.delete(job.id);
70
+ }
71
+ }
72
+ async runLoop() {
73
+ await this.reconcileOnce();
74
+ for (;;) {
75
+ await this.tickOnce();
76
+ await sleep(this.config.tickSeconds * 1_000);
77
+ }
78
+ }
79
+ async reconcileOnce(nowMs = Date.now()) {
80
+ const abandoned = this.store.abandonRunningCronRuns(nowMs);
81
+ if (abandoned > 0) {
82
+ this.logger.log("warn", `abandoned ${abandoned} stale cron runs on startup`);
83
+ }
84
+ const storedTimeZone = this.readStoredEffectiveTimeZone();
85
+ const previousTimeZone = storedTimeZone ?? LEGACY_CRON_TIME_ZONE;
86
+ if (previousTimeZone !== this.effectiveTimeZone) {
87
+ const message = storedTimeZone === null
88
+ ? `rebasing enabled cron jobs from legacy ${LEGACY_CRON_TIME_ZONE} semantics to ${this.effectiveTimeZone}`
89
+ : `cron time zone changed from ${previousTimeZone} to ${this.effectiveTimeZone}; rebasing enabled jobs`;
90
+ this.logger.log("warn", message);
91
+ this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled), nowMs);
92
+ }
93
+ else {
94
+ this.rebaseJobs(this.store.listOverdueCronJobs(nowMs), nowMs);
95
+ }
96
+ this.store.putStateValue(CRON_EFFECTIVE_TIME_ZONE_KEY, this.effectiveTimeZone, nowMs);
97
+ }
98
+ async tickOnce(nowMs = Date.now()) {
99
+ const capacity = this.config.maxConcurrentRuns - this.runningJobIds.size;
100
+ if (capacity <= 0) {
101
+ return;
102
+ }
103
+ const dueJobs = this.store.listDueCronJobs(nowMs, capacity);
104
+ for (const job of dueJobs) {
105
+ if (this.runningJobIds.has(job.id)) {
106
+ continue;
107
+ }
108
+ this.runningJobIds.add(job.id);
109
+ void this.executeJob(job, job.nextRunAtMs, nowMs)
110
+ .catch((error) => {
111
+ this.logger.log("error", `cron job ${job.id} failed: ${formatError(error)}`);
112
+ })
113
+ .finally(() => {
114
+ this.runningJobIds.delete(job.id);
115
+ });
116
+ if (this.runningJobIds.size >= this.config.maxConcurrentRuns) {
117
+ return;
118
+ }
119
+ }
120
+ }
121
+ async executeJob(job, scheduledForMs, nextRunBaseMs) {
122
+ const startedAtMs = Date.now();
123
+ if (nextRunBaseMs !== null) {
124
+ const nextRunAtMs = computeNextRunAt(this.contract, job, Math.max(nextRunBaseMs, scheduledForMs), this.effectiveTimeZone);
125
+ this.store.updateCronJobNextRun(job.id, nextRunAtMs, startedAtMs);
126
+ }
127
+ const runId = this.store.insertCronRun(job.id, scheduledForMs, startedAtMs);
128
+ try {
129
+ const report = await this.executor.dispatchCronJob(toBindingCronJobSpec(job));
130
+ this.store.finishCronRun(runId, "succeeded", Date.now(), report.responseText, null);
131
+ return report;
132
+ }
133
+ catch (error) {
134
+ const message = formatError(error);
135
+ this.store.finishCronRun(runId, "failed", Date.now(), null, message);
136
+ throw error;
137
+ }
138
+ }
139
+ requireJob(id) {
140
+ const job = this.store.getCronJob(id);
141
+ if (job === null) {
142
+ throw new Error(`unknown cron job: ${id}`);
143
+ }
144
+ return job;
145
+ }
146
+ rebaseJobs(jobs, nowMs) {
147
+ for (const job of jobs) {
148
+ try {
149
+ const nextRunAtMs = computeNextRunAt(this.contract, job, nowMs, this.effectiveTimeZone);
150
+ this.store.updateCronJobNextRun(job.id, nextRunAtMs, nowMs);
151
+ }
152
+ catch (error) {
153
+ this.logger.log("error", `failed to rebase cron job ${job.id}: ${formatError(error)}`);
154
+ }
155
+ }
156
+ }
157
+ readStoredEffectiveTimeZone() {
158
+ const stored = this.store.getStateValue(CRON_EFFECTIVE_TIME_ZONE_KEY);
159
+ if (stored === null) {
160
+ return null;
161
+ }
162
+ try {
163
+ return this.contract.normalizeCronTimeZone(stored);
164
+ }
165
+ catch (error) {
166
+ this.logger.log("warn", `stored cron time zone is invalid (${stored}); treating as legacy ${LEGACY_CRON_TIME_ZONE}: ${formatError(error)}`);
167
+ return null;
168
+ }
169
+ }
170
+ }
171
+ function normalizeUpsertInput(input) {
172
+ const id = normalizeId(input.id);
173
+ const schedule = normalizeRequiredField(input.schedule, "cron schedule");
174
+ const prompt = normalizeRequiredField(input.prompt, "cron prompt");
175
+ const deliveryChannel = normalizeOptionalField(input.deliveryChannel);
176
+ const deliveryTarget = normalizeOptionalField(input.deliveryTarget);
177
+ const deliveryTopic = normalizeOptionalField(input.deliveryTopic);
178
+ if ((deliveryChannel === null) !== (deliveryTarget === null)) {
179
+ throw new Error("cron delivery_channel and delivery_target must be provided together");
180
+ }
181
+ if (deliveryChannel === null && deliveryTopic !== null) {
182
+ throw new Error("cron delivery_topic requires delivery_channel and delivery_target");
183
+ }
184
+ if (deliveryChannel !== null && deliveryChannel !== "telegram") {
185
+ throw new Error(`unsupported cron delivery channel: ${deliveryChannel}`);
186
+ }
187
+ return {
188
+ id,
189
+ schedule,
190
+ prompt,
191
+ enabled: input.enabled,
192
+ deliveryChannel,
193
+ deliveryTarget,
194
+ deliveryTopic,
195
+ nextRunAtMs: 0,
196
+ recordedAtMs: 0,
197
+ };
198
+ }
199
+ function normalizeId(id) {
200
+ return normalizeRequiredField(id, "cron id");
201
+ }
202
+ function normalizeRequiredField(value, field) {
203
+ const trimmed = value.trim();
204
+ if (trimmed.length === 0) {
205
+ throw new Error(`${field} must not be empty`);
206
+ }
207
+ return trimmed;
208
+ }
209
+ function normalizeOptionalField(value) {
210
+ if (value === null) {
211
+ return null;
212
+ }
213
+ const trimmed = value.trim();
214
+ return trimmed.length === 0 ? null : trimmed;
215
+ }
216
+ function toBindingCronJobSpec(job) {
217
+ return {
218
+ id: job.id,
219
+ schedule: job.schedule,
220
+ prompt: job.prompt,
221
+ deliveryChannel: job.deliveryChannel,
222
+ deliveryTarget: job.deliveryTarget,
223
+ deliveryTopic: job.deliveryTopic,
224
+ };
225
+ }
226
+ function computeNextRunAt(contract, job, afterMs, timeZone) {
227
+ const nextRunAt = contract.nextCronRunAt(toBindingCronJobSpec(job), afterMs, timeZone);
228
+ if (!Number.isSafeInteger(nextRunAt) || nextRunAt < 0) {
229
+ throw new Error(`next cron run at is out of range for JavaScript: ${nextRunAt}`);
230
+ }
231
+ return nextRunAt;
232
+ }
233
+ function sleep(durationMs) {
234
+ return new Promise((resolve) => {
235
+ setTimeout(resolve, durationMs);
236
+ });
237
+ }
@@ -0,0 +1,16 @@
1
+ import type { BindingDeliveryTarget, BindingLoggerHost } from "../binding";
2
+ import type { SqliteStore } from "../store/sqlite";
3
+ import type { TelegramDeliveryClientLike } from "../telegram/client";
4
+ export type DeliveryModePreference = "auto" | "oneshot" | "stream";
5
+ export type ResolvedDeliveryMode = "oneshot" | "progressive";
6
+ export declare class TelegramProgressiveSupport {
7
+ private readonly client;
8
+ private readonly store;
9
+ private readonly logger;
10
+ constructor(client: TelegramDeliveryClientLike | null, store: SqliteStore, logger: BindingLoggerHost);
11
+ resolveMode(target: BindingDeliveryTarget, preference: DeliveryModePreference): Promise<ResolvedDeliveryMode>;
12
+ sendDraft(target: BindingDeliveryTarget, draftId: number, text: string): Promise<void>;
13
+ startTyping(target: BindingDeliveryTarget): void;
14
+ private isPrivateChat;
15
+ }
16
+ export declare function createDraftId(): number;
@@ -0,0 +1,75 @@
1
+ import { readTelegramChatType, recordTelegramChatType, recordTelegramDraftFailure, recordTelegramDraftSuccess, recordTelegramStreamFallback, } from "../telegram/state";
2
+ import { formatError } from "../utils/error";
3
+ export class TelegramProgressiveSupport {
4
+ client;
5
+ store;
6
+ logger;
7
+ constructor(client, store, logger) {
8
+ this.client = client;
9
+ this.store = store;
10
+ this.logger = logger;
11
+ }
12
+ async resolveMode(target, preference) {
13
+ if (target.channel !== "telegram" || preference === "oneshot") {
14
+ return "oneshot";
15
+ }
16
+ const isPrivateChat = await this.isPrivateChat(target.target);
17
+ if (preference === "stream") {
18
+ if (!isPrivateChat) {
19
+ throw new Error("telegram draft stream is only supported for private chats");
20
+ }
21
+ return "progressive";
22
+ }
23
+ if (!isPrivateChat) {
24
+ recordTelegramStreamFallback(this.store, "non_private_chat", Date.now());
25
+ return "oneshot";
26
+ }
27
+ return "progressive";
28
+ }
29
+ async sendDraft(target, draftId, text) {
30
+ if (this.client === null) {
31
+ throw new Error("telegram transport is not configured");
32
+ }
33
+ try {
34
+ await this.client.sendMessageDraft(target.target, draftId, text, target.topic);
35
+ recordTelegramDraftSuccess(this.store, Date.now());
36
+ }
37
+ catch (error) {
38
+ const message = formatError(error);
39
+ recordTelegramDraftFailure(this.store, message, Date.now());
40
+ recordTelegramStreamFallback(this.store, "draft_send_failed", Date.now());
41
+ this.logger.log("warn", `telegram draft send failed: ${message}`);
42
+ throw error;
43
+ }
44
+ }
45
+ startTyping(target) {
46
+ if (this.client === null) {
47
+ return;
48
+ }
49
+ void this.client.sendChatAction(target.target, "typing", target.topic).catch(() => {
50
+ // Typing hints are best-effort only.
51
+ });
52
+ }
53
+ async isPrivateChat(chatId) {
54
+ const cachedChatType = readTelegramChatType(this.store, chatId);
55
+ if (cachedChatType !== null) {
56
+ return cachedChatType === "private";
57
+ }
58
+ if (this.client === null) {
59
+ return false;
60
+ }
61
+ try {
62
+ const chat = await this.client.getChat(chatId);
63
+ recordTelegramChatType(this.store, chatId, chat.type, Date.now());
64
+ return chat.type === "private";
65
+ }
66
+ catch (error) {
67
+ this.logger.log("warn", `failed to resolve Telegram chat type for ${chatId}: ${formatError(error)}`);
68
+ return false;
69
+ }
70
+ }
71
+ }
72
+ export function createDraftId() {
73
+ const draftId = crypto.getRandomValues(new Uint32Array(1))[0];
74
+ return draftId === 0 ? 1 : draftId;
75
+ }
@@ -0,0 +1,21 @@
1
+ import type { BindingDeliveryTarget } from "../binding";
2
+ import type { GatewayTransportHost } from "../host/transport";
3
+ import type { SqliteStore } from "../store/sqlite";
4
+ import { type DeliveryModePreference, type TelegramProgressiveSupport } from "./telegram";
5
+ export type TextDeliverySession = {
6
+ mode: "oneshot" | "progressive";
7
+ preview(text: string): Promise<void>;
8
+ finish(finalText: string | null): Promise<boolean>;
9
+ };
10
+ export declare class GatewayTextDelivery {
11
+ private readonly transport;
12
+ private readonly store;
13
+ private readonly telegramSupport;
14
+ constructor(transport: GatewayTransportHost, store: SqliteStore, telegramSupport: TelegramProgressiveSupport);
15
+ open(target: BindingDeliveryTarget, preference: DeliveryModePreference): Promise<TextDeliverySession>;
16
+ openMany(targets: BindingDeliveryTarget[], preference: DeliveryModePreference): Promise<TextDeliverySession[]>;
17
+ sendTest(target: BindingDeliveryTarget, text: string, preference: DeliveryModePreference): Promise<{
18
+ delivered: boolean;
19
+ mode: "oneshot" | "progressive";
20
+ }>;
21
+ }
@@ -0,0 +1,175 @@
1
+ import { recordTelegramPreviewEmit, recordTelegramStreamFallback } from "../telegram/state";
2
+ import { createDraftId } from "./telegram";
3
+ export class GatewayTextDelivery {
4
+ transport;
5
+ store;
6
+ telegramSupport;
7
+ constructor(transport, store, telegramSupport) {
8
+ this.transport = transport;
9
+ this.store = store;
10
+ this.telegramSupport = telegramSupport;
11
+ }
12
+ async open(target, preference) {
13
+ const [session] = await this.openMany([target], preference);
14
+ return session;
15
+ }
16
+ async openMany(targets, preference) {
17
+ const uniqueTargets = dedupeTargets(targets);
18
+ if (uniqueTargets.length === 0) {
19
+ return [new NoopTextDeliverySession()];
20
+ }
21
+ const sessions = await Promise.all(uniqueTargets.map(async (target) => {
22
+ const mode = await this.telegramSupport.resolveMode(target, preference);
23
+ if (mode === "progressive") {
24
+ const session = new ProgressiveTextDeliverySession(target, this.transport, this.telegramSupport, this.store);
25
+ session.start();
26
+ return session;
27
+ }
28
+ return new OneshotTextDeliverySession(target, this.transport);
29
+ }));
30
+ if (sessions.length === 1) {
31
+ return sessions;
32
+ }
33
+ return [new FanoutTextDeliverySession(sessions)];
34
+ }
35
+ async sendTest(target, text, preference) {
36
+ const session = await this.open(target, preference);
37
+ if (session.mode === "progressive") {
38
+ await session.preview(text.slice(0, Math.max(1, Math.ceil(text.length / 2))));
39
+ }
40
+ return {
41
+ delivered: await session.finish(text),
42
+ mode: session.mode,
43
+ };
44
+ }
45
+ }
46
+ class NoopTextDeliverySession {
47
+ mode = "oneshot";
48
+ async preview(_text) { }
49
+ async finish(_finalText) {
50
+ return false;
51
+ }
52
+ }
53
+ class FanoutTextDeliverySession {
54
+ sessions;
55
+ mode;
56
+ constructor(sessions) {
57
+ this.sessions = sessions;
58
+ this.mode = sessions.some((session) => session.mode === "progressive") ? "progressive" : "oneshot";
59
+ }
60
+ async preview(text) {
61
+ await Promise.all(this.sessions.map((session) => session.preview(text)));
62
+ }
63
+ async finish(finalText) {
64
+ const results = await Promise.allSettled(this.sessions.map((session) => session.finish(finalText)));
65
+ const firstFailure = results.find((result) => result.status === "rejected");
66
+ if (firstFailure?.status === "rejected") {
67
+ throw firstFailure.reason;
68
+ }
69
+ return results.some((result) => result.status === "fulfilled" && result.value);
70
+ }
71
+ }
72
+ class OneshotTextDeliverySession {
73
+ target;
74
+ transport;
75
+ mode = "oneshot";
76
+ constructor(target, transport) {
77
+ this.target = target;
78
+ this.transport = transport;
79
+ }
80
+ async preview(_text) { }
81
+ async finish(finalText) {
82
+ if (finalText === null || finalText.trim().length === 0) {
83
+ return false;
84
+ }
85
+ const ack = await this.transport.sendMessage({
86
+ deliveryTarget: this.target,
87
+ body: finalText,
88
+ });
89
+ if (ack.errorMessage !== null) {
90
+ throw new Error(ack.errorMessage);
91
+ }
92
+ return true;
93
+ }
94
+ }
95
+ class ProgressiveTextDeliverySession {
96
+ target;
97
+ transport;
98
+ telegramSupport;
99
+ store;
100
+ mode = "progressive";
101
+ previewFailed = false;
102
+ previewDelivered = false;
103
+ closed = false;
104
+ pendingPreviewCount = 0;
105
+ pendingPreview = Promise.resolve();
106
+ draftId = createDraftId();
107
+ constructor(target, transport, telegramSupport, store) {
108
+ this.target = target;
109
+ this.transport = transport;
110
+ this.telegramSupport = telegramSupport;
111
+ this.store = store;
112
+ }
113
+ start() {
114
+ this.telegramSupport.startTyping(this.target);
115
+ }
116
+ async preview(text) {
117
+ if (this.previewFailed || this.closed) {
118
+ return;
119
+ }
120
+ const runPreview = async () => {
121
+ try {
122
+ if (this.previewFailed || this.closed) {
123
+ return;
124
+ }
125
+ try {
126
+ recordTelegramPreviewEmit(this.store, Date.now());
127
+ await this.telegramSupport.sendDraft(this.target, this.draftId, text);
128
+ this.previewDelivered = true;
129
+ }
130
+ catch {
131
+ this.previewFailed = true;
132
+ }
133
+ }
134
+ finally {
135
+ this.pendingPreviewCount = Math.max(0, this.pendingPreviewCount - 1);
136
+ }
137
+ };
138
+ this.pendingPreviewCount += 1;
139
+ this.pendingPreview = this.pendingPreview.then(runPreview, runPreview);
140
+ await this.pendingPreview;
141
+ }
142
+ async finish(finalText) {
143
+ this.closed = true;
144
+ if (this.pendingPreviewCount > 0) {
145
+ await this.pendingPreview;
146
+ }
147
+ if (finalText === null || finalText.trim().length === 0) {
148
+ return false;
149
+ }
150
+ if (!this.previewDelivered && !this.previewFailed) {
151
+ recordTelegramStreamFallback(this.store, "preview_not_established", Date.now());
152
+ }
153
+ const ack = await this.transport.sendMessage({
154
+ deliveryTarget: this.target,
155
+ body: finalText,
156
+ });
157
+ if (ack.errorMessage !== null) {
158
+ throw new Error(ack.errorMessage);
159
+ }
160
+ return true;
161
+ }
162
+ }
163
+ function dedupeTargets(targets) {
164
+ const seen = new Set();
165
+ const unique = [];
166
+ for (const target of targets) {
167
+ const key = `${target.channel}:${target.target}:${target.topic ?? ""}`;
168
+ if (seen.has(key)) {
169
+ continue;
170
+ }
171
+ seen.add(key);
172
+ unique.push(target);
173
+ }
174
+ return unique;
175
+ }
@@ -0,0 +1,33 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import type { GatewayBindingModule, GatewayContract } from "./binding";
3
+ import { GatewayCronRuntime } from "./cron/runtime";
4
+ import { ChannelFileSender } from "./host/file-sender";
5
+ import { GatewayExecutor } from "./runtime/executor";
6
+ import { GatewaySessionContext } from "./session/context";
7
+ import { ChannelSessionSwitcher } from "./session/switcher";
8
+ import { GatewayTelegramRuntime } from "./telegram/runtime";
9
+ export type GatewayPluginStatus = {
10
+ runtimeMode: string;
11
+ supportsTelegram: boolean;
12
+ supportsCron: boolean;
13
+ hasWebUi: boolean;
14
+ cronTimezone: string;
15
+ cronEnabled: boolean;
16
+ cronPolling: boolean;
17
+ cronRunningJobs: number;
18
+ telegramEnabled: boolean;
19
+ telegramPolling: boolean;
20
+ telegramAllowlistMode: "disabled" | "explicit";
21
+ };
22
+ export declare class GatewayPluginRuntime {
23
+ readonly contract: GatewayContract;
24
+ readonly executor: GatewayExecutor;
25
+ readonly cron: GatewayCronRuntime;
26
+ readonly telegram: GatewayTelegramRuntime;
27
+ readonly files: ChannelFileSender;
28
+ readonly channelSessions: ChannelSessionSwitcher;
29
+ readonly sessionContext: GatewaySessionContext;
30
+ constructor(contract: GatewayContract, executor: GatewayExecutor, cron: GatewayCronRuntime, telegram: GatewayTelegramRuntime, files: ChannelFileSender, channelSessions: ChannelSessionSwitcher, sessionContext: GatewaySessionContext);
31
+ status(): GatewayPluginStatus;
32
+ }
33
+ export declare function createGatewayRuntime(module: GatewayBindingModule, input: PluginInput): Promise<GatewayPluginRuntime>;