Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- [Telegram](./docs/user/telegram.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
- [Run T3 Code as a background service](./docs/user/background-service.md)

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
[WS_METHODS.serverTestTelegram]: AuthSettingsWriteScope,
[WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
[WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
[WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import * as TelegramHandlers from "./toolkits/telegram/handlers.ts";
import { TelegramToolkit } from "./toolkits/telegram/tools.ts";
import * as DeviceHandlers from "./toolkits/device/handlers.ts";
import {
DeviceScreenshotTool,
Expand Down Expand Up @@ -825,6 +827,8 @@ export const layerPullRequestsToolkit = toolkitRegistration(
PullRequestsHandlers.layer,
);

const layerTelegramRegistration = toolkitRegistration(TelegramToolkit, TelegramHandlers.layer);

const layerDeviceStandardToolkitRegistration = toolkitRegistration(
DeviceStandardToolkit,
DeviceHandlers.layerStandard,
Expand Down Expand Up @@ -857,6 +861,7 @@ export const layer = Layer.mergeAll(
layerPreviewControlsRegistration,
layerWorktreeToolkitRegistration,
layerPullRequestsToolkit,
layerTelegramRegistration,
layerDeviceToolkit,
layerHtmlToolkit,
).pipe(Layer.provideMerge(layerMcpTransport));
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const declaredFailure = (result: McpSchema.CallToolResult) => {
};
import { PullRequestsToolkit } from "./pullRequests/tools.ts";
import { HtmlToolkit } from "./html/tools.ts";
import { TelegramToolkit } from "./telegram/tools.ts";
import {
resolveT3McpToolDefinition,
resolveT3McpToolPresentation,
Expand All @@ -81,6 +82,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
DeviceToolkit,
PullRequestsToolkit,
HtmlToolkit,
TelegramToolkit,
]) {
for (const tool of Object.values(toolkit.tools)) {
expect(names.has(tool.name)).toBe(false);
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/mcp/toolkits/telegram/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, it } from "@effect/vitest";
import {
EnvironmentId,
ProviderInstanceId,
TelegramSendError,
ThreadId,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";

import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
import * as TelegramChannel from "../../../telegram/TelegramChannel.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import * as TelegramHandlers from "./handlers.ts";
import { TelegramToolkit } from "./tools.ts";

const callerThreadId = ThreadId.make("telegram-caller");
const providerInstanceId = ProviderInstanceId.make("codex");

const callTelegramSend = (
send: TelegramChannel.TelegramChannel["Service"]["send"],
parameters: {
readonly text?: string;
readonly path?: string;
readonly chatId?: number;
readonly chat?: string;
},
runtimeMode: OrchestrationV2ThreadShell["runtimeMode"] = "full-access",
) => {
const layerDependencies = Layer.mergeAll(
Layer.succeed(McpInvocationContext.McpInvocationContext, {
environmentId: EnvironmentId.make("environment"),
requestNamespace: "session",
thread: { threadId: callerThreadId, providerSessionId: "session", providerInstanceId },
client: undefined,
issuedAt: 0,
capabilities: new Set(["orchestration" as const]),
}),
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: () =>
Effect.succeed({
id: callerThreadId,
providerInstanceId,
runtimeMode,
interactionMode: "default",
activeRunId: "active-run",
archivedAt: null,
deletedAt: null,
} as OrchestrationV2ThreadShell),
}),
Layer.mock(TelegramChannel.TelegramChannel)({ send }),
);
return TelegramToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(TelegramHandlers.layer).pipe(
Layer.provide(layerDependencies),
),
),
Effect.flatMap((toolkit) => toolkit.handle("telegram_send", parameters)),
Stream.unwrap,
Stream.runCollect,
Effect.map((results) => results.at(-1)?.result),
Effect.provide(layerDependencies),
);
};

it.effect("sends on behalf of the calling thread, so the answer comes back to it", () =>
Effect.gen(function* () {
const requests: Array<TelegramChannel.TelegramSendInput> = [];
const result = yield* callTelegramSend(
(input) => {
requests.push(input);
return Effect.succeed({ chatId: 42, messageId: 7, replyAvailable: true });
},
{ text: "Build finished", chatId: 42 },
);
expect(result).toEqual({ chatId: 42, messageId: 7, replyAvailable: true });
// A full-access thread may send a file from outside its workspace.
expect(requests).toEqual([
{ threadId: callerThreadId, text: "Build finished", chatId: 42, hostFiles: true },
]);
}),
);

it.effect("does not let a thread without full access send files from outside its workspace", () =>
Effect.gen(function* () {
const requests: Array<TelegramChannel.TelegramSendInput> = [];
yield* callTelegramSend(
(input) => {
requests.push(input);
return Effect.succeed({ chatId: 42, messageId: 7, replyAvailable: true });
},
{ path: "/etc/hosts" },
"approval-required",
);
expect(requests).toEqual([{ threadId: callerThreadId, path: "/etc/hosts", hostFiles: false }]);
}),
);

it.effect.each([
{ reason: "file-unreadable", code: "invalid_request" },
{ reason: "not-configured", code: "invalid_request" },
{ reason: "rejected", code: "invalid_request" },
{ reason: "unreachable", code: "orchestration_error" },
{ reason: "context-unavailable", code: "orchestration_error" },
{ reason: "reply-unavailable", code: "invalid_request" },
{ reason: "chat-not-found", code: "invalid_request" },
{ reason: "file-outside-workspace", code: "invalid_request" },
] as const)("tells the agent why a $reason send failed", ({ reason, code }) =>
Effect.gen(function* () {
const error = new TelegramSendError({ reason });
const result = yield* callTelegramSend(() => Effect.fail(error), { text: "Build finished" });
expect(result).toMatchObject({ _tag: "OrchestratorMcpFailure", code, message: error.message });
}),
);
32 changes: 32 additions & 0 deletions apps/server/src/mcp/toolkits/telegram/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { OrchestratorMcpFailure } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import * as TelegramChannel from "../../../telegram/TelegramChannel.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import { loadCaller } from "../../threadAccess.ts";
import { TelegramToolkit } from "./tools.ts";

export const layer = McpToolAccess.toLayer(TelegramToolkit, {
telegram_send: McpToolAccess.actsAsCaller((input) =>
Effect.gen(function* () {
const { caller, limits } = yield* loadCaller();
const telegram = yield* TelegramChannel.TelegramChannel;
return yield* telegram
.send({ ...input, threadId: caller?.id, hostFiles: limits.runtimeMode === "full-access" })
.pipe(
Effect.mapError(
(error) =>
new OrchestratorMcpFailure({
// Only a send Telegram never answered is worth another try; what it refused,
// and what the request itself got wrong, will fail the same way again.
code:
error.reason === "unreachable" || error.reason === "context-unavailable"
? "orchestration_error"
: "invalid_request",
message: error.message,
}),
),
);
}),
),
});
42 changes: 42 additions & 0 deletions apps/server/src/mcp/toolkits/telegram/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { OrchestratorMcpFailure, TelegramId, TelegramSendResult } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/ai";

import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as TelegramChannel from "../../../telegram/TelegramChannel.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const TelegramSendTool = Tool.make("telegram_send", {
description:
"Send a message or a file to the user's Telegram chat, or to another chat or group listed in Settings. Use it when the user asks to be told or sent something on Telegram, and to answer a message that says it was sent from Telegram. Give text, path, or both. Text is shown as written, without Markdown, up to 4096 characters, or 1024 as the caption of a file. JPG, PNG, and WebP images and MP4 videos play in the chat; any other file, such as a PDF, arrives as a document. Files can be up to 50 MB. The user can answer the message in Telegram, and the answer arrives in this thread. A successful result means the message was sent: if replyAvailable is false, explain the warning but do not resend the file.",
parameters: Schema.Struct({
text: Schema.optional(Schema.String).annotate({
description: "The message, or the caption when a file is sent.",
}),
path: Schema.optional(Schema.String).annotate({
description:
"A file to upload. A relative path is read from this thread's workspace; an absolute path is read in place.",
}),
chatId: Schema.optional(TelegramId).annotate({
description:
"Use the chatId supplied by an incoming Telegram message when answering it. Otherwise use a configured destination. When omitted, replies go to the latest Telegram sender in the active turn; other sends use the first configured chat.",
}),
chat: Schema.optional(Schema.String).annotate({
description:
"The name of a chat or group from Settings, when the user names one, such as the team group. Part of the name is enough when only one chat fits. Leave it out to answer a Telegram message or to use the default chat.",
}),
}),
success: TelegramSendResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
TelegramChannel.TelegramChannel,
],
})
.annotate(Tool.Title, "Send to Telegram")
.annotate(Tool.Destructive, false)
.annotate(Tool.OpenWorld, true);

export const TelegramToolkit = Toolkit.make(TelegramSendTool);
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../../../secrets/SecretRequests.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as TelegramChannel from "../../../telegram/TelegramChannel.ts";
import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
import * as McpHttpServer from "../../McpHttpServer.ts";
import * as McpSessionRegistry from "../../McpSessionRegistry.ts";
Expand All @@ -37,6 +38,7 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(TelegramChannel.TelegramChannel)({}),
ServerSettings.layerTest({}),
Layer.mock(GitWorkflowService.GitWorkflowService)({}),
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverRemoveKeybinding]: "server",
[WS_METHODS.serverGetSettings]: "server",
[WS_METHODS.serverUpdateSettings]: "server",
[WS_METHODS.serverTestTelegram]: "server",
[WS_METHODS.serverSearchAcpRegistry]: "server",
[WS_METHODS.serverPrepareAcpRegistryAgent]: "server",
[WS_METHODS.serverUninstallAcpRegistryManagedBinary]: "server",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/persistence/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,6 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis
export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type TelegramMessageRepositoryError = PersistenceSqlError | PersistenceDecodeError;

export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts"
import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
import Migration0060 from "./Migrations/060_TelegramMessages.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -146,6 +147,7 @@ export const migrationEntries = [
[57, "ScheduledTaskWebhooks", Migration0057],
[58, "WebhookRelayDeliveries", Migration0058],
[59, "McpAppModelContext", Migration0059],
[60, "TelegramMessages", Migration0060],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 59 }, (_, index) => index + 1),
Array.from({ length: 60 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -31,6 +31,7 @@ layer("055_OrchestrationV2", (it) => {
[57, "ScheduledTaskWebhooks"],
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "TelegramMessages"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand All @@ -56,6 +57,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 57, name: "ScheduledTaskWebhooks" },
{ migration_id: 58, name: "WebhookRelayDeliveries" },
{ migration_id: 59, name: "McpAppModelContext" },
{ migration_id: 60, name: "TelegramMessages" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/persistence/Migrations/060_TelegramMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// One row per message the bot sent on behalf of a thread. Telegram only tells a bot which
// message somebody answered, so this is what turns that answer into the thread it is for.
// A message id is only unique within one bot's view of one chat, so a different bot's
// messages in the same chat start over at the same numbers.
yield* sql`
CREATE TABLE IF NOT EXISTS telegram_messages (
bot_id TEXT NOT NULL,
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
thread_id TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (bot_id, chat_id, message_id)
) WITHOUT ROWID
`;
});
Loading
Loading