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
34 changes: 34 additions & 0 deletions starter/slack-community-pulse/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Local HTTP worker and public tunnel used to configure Slack.
PORT=3001
PUBLIC_BASE_URL=https://your-public-tunnel.example

# PUBLIC_BASE_URL is setup context only; the worker does not use it for routing.
# Configure Slack's Events API request URL as:
# ${PUBLIC_BASE_URL}/api/tag/slack/webhook

# Slack HTTP Events ingress. Keep real values in the ignored .env file.
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=xoxb-...

# Read-only X OAuth 1.0a credentials.
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_TOKEN_SECRET=

# Optional default when the Slack prompt does not include an X handle.
X_COMMUNITY_HANDLE=corbitsdev

# Pick whichever model provider you have configured.
ANTHROPIC_API_KEY=
# ANTHROPIC_MODEL=claude-sonnet-4-6
# OPENAI_API_KEY=
# OPENAI_BASE_URL=
# OPENAI_MODEL=gpt-4o-mini
# GOOGLE_API_KEY=
# GEMINI_API_KEY=
# GOOGLE_MODEL=gemini-2.0-flash

# Optional overrides.
# INTX_PROVIDER=anthropic
# INTX_MODEL=
8 changes: 8 additions & 0 deletions starter/slack-community-pulse/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
tmp/
bun.lock
*.log
.env
.env.*
!.env.example
!.env.*.example
84 changes: 84 additions & 0 deletions starter/slack-community-pulse/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# slack-community-pulse

A report-only Slack workflow where two Interchange analysts collect and filter
their own public X evidence in parallel, then a reporter merges their findings:

```text
Slack mention or DM
├─> Community Listener -> x_get_user_mentions ─┐
└─> Content Analyst -> x_get_user_posts ─────┴─> Pulse Reporter -> Slack
```

Each analyst must call its assigned read-only X tool before its workflow step
can complete. The workflow cannot post, reply, like, or otherwise mutate X. It
analyzes only public likes, replies, reposts, quotes, and post text. Collection
is capped at two 100-item pages per endpoint and compares adjacent seven-day
periods.

## Stacked dependencies

This example is intentionally stacked on the Slack post-to-X change. The only
code dependency it shares with the preceding stack is:

- the pinned Corbits Tag workspace at
`../slack-agent/vendor/corbits-tag`;

There is no `vendor/` directory in this example. It does not add another
submodule, shared bridge, or root workspace change. Provider resolution,
exports, workflow code, X tools/client, configuration, sessions, and Slack
entrypoint are all isolated inside `starter/slack-community-pulse`. Interchange
packages come from npm at `0.2.2`.

## Setup

1. Clone with submodules and install from this directory:

```bash
git clone --recurse-submodules https://github.com/corbitsdev/examples.git
cd examples/starter/slack-community-pulse
bun install
cp .env.example .env
```

For an existing clone, initialize the shared Corbits Tag checkout first:

```bash
git submodule update --init --recursive starter/slack-agent/vendor/corbits-tag
```

2. Replace the public URL in `manifest.slack.json`, create the Slack app from
that manifest, and install it.

3. Fill `.env` with Slack credentials, the four read-only X OAuth credentials,
and one supported inference-provider key.

4. Expose port `3001` through HTTPS and start the worker:

```bash
bun run start
```

The Slack Events API URL is:

```text
POST https://your-public-host/api/tag/slack/webhook
```

Mention the Corbits bot in a channel or send it a DM:

```text
@corbits-community-pulse generate the weekly community pulse for @corbitsdev
```

Set `X_COMMUNITY_HANDLE` when prompts should be allowed to omit the handle.
Slack state and active-run ownership are process-local; restarting the worker
clears them.

## Local checks

```bash
bun install --frozen-lockfile
bun run typecheck
bun build src/cli.ts --target=bun --outdir=tmp/build
bun run start --help
```
412 changes: 412 additions & 0 deletions starter/slack-community-pulse/bun.lock

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions starter/slack-community-pulse/manifest.slack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"display_information": {
"name": "corbits-community-pulse",
"description": "Create a read-only weekly X community pulse report with Corbits",
"background_color": "#111111"
},
"features": {
"bot_user": {
"display_name": "corbits-community-pulse",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"chat:write",
"im:history",
"users:read",
"users:read.email"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://your-public-tunnel.example/api/tag/slack/webhook",
"bot_events": [
"app_mention",
"message.im"
]
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false,
"token_rotation_enabled": false
}
}
33 changes: 33 additions & 0 deletions starter/slack-community-pulse/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@corbits/example-slack-community-pulse",
"version": "0.1.0",
"license": "LGPL-2.1-only",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"workspaces": [
"../slack-agent/vendor/corbits-tag/packages/*"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"start": "bun run src/cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@chat-adapter/state-memory": "^4.34.0",
"@corbits/tag-slack": "workspace:*",
"@intx/agent": "0.2.2",
"@intx/storage-isogit": "0.2.2",
"@intx/workflow": "0.2.2",
"hono": "^4.12.31"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.3"
}
}
68 changes: 68 additions & 0 deletions starter/slack-community-pulse/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createMemoryState } from "@chat-adapter/state-memory";
import { mountSlackTag } from "@corbits/tag-slack";
import { Hono } from "hono";

import { resolveConfig, SERVICE_NAME } from "./config";
import { createCommunityPulseSessions } from "./session";

export type MainOptions = {
stdout?: (text: string) => void;
stderr?: (text: string) => void;
contextRoot?: string;
};

export async function main(
argv: string[],
env: NodeJS.ProcessEnv,
options: MainOptions = {},
): Promise<number> {
const stdout =
options.stdout ?? ((text: string) => void process.stdout.write(text));
const stderr =
options.stderr ?? ((text: string) => void process.stderr.write(text));
if (argv.includes("--help") || argv.includes("-h")) {
stdout(
"usage: bun run start\n\nStart the report-only Slack weekly community pulse workflow.\n",
);
return 0;
}

const resolved = resolveConfig(env, options.contextRoot);
if (resolved.error !== undefined) {
stderr(resolved.error);
return 1;
}

const sessions = createCommunityPulseSessions(resolved.config, stderr);
const app = new Hono();
const mounted = mountSlackTag(app, {
userName: "corbits-community-pulse",
state: createMemoryState(),
slack: {
botToken: resolved.config.botToken,
signingSecret: resolved.config.signingSecret,
},
subscribeOnMention: false,
onTag: sessions.start,
});

try {
Bun.serve({ port: resolved.config.port, fetch: app.fetch });
} catch (error) {
stderr(`${message(error)}\n`);
return 1;
}
stdout(
`${SERVICE_NAME} listening on http://localhost:${resolved.config.port}${mounted.path}\n`,
);
return await new Promise<never>(() => undefined);
}

function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

if (import.meta.main) {
const code = await main(process.argv.slice(2), process.env);
if (code !== 0) process.exit(code);
}
80 changes: 80 additions & 0 deletions starter/slack-community-pulse/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { join } from "node:path";

import {
resolveSource,
type Source,
} from "./source";
import { kind } from "./workflow";
import {
createXCommunityClient,
type XCommunityClient,
} from "./x-client";

export const SERVICE_NAME = kind;

export type CommunityPulseConfig = {
port: number;
signingSecret: string;
botToken: string;
source: Source;
xClient: XCommunityClient;
defaultHandle?: string;
contextRoot: string;
};

export type ResolveConfigResult =
| { config: CommunityPulseConfig; error?: undefined }
| { config?: undefined; error: string };

export function resolveConfig(
env: NodeJS.ProcessEnv,
contextRootOverride?: string,
): ResolveConfigResult {
const signingSecret = env.SLACK_SIGNING_SECRET?.trim();
const botToken = env.SLACK_BOT_TOKEN?.trim();
if (!signingSecret || !botToken) {
return { error: "SLACK_SIGNING_SECRET and SLACK_BOT_TOKEN are required.\n" };
}

const port = Number(env.PORT ?? "3001");
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
return { error: `PORT="${env.PORT ?? ""}" is not valid.\n` };
}

const xKeys = [
"X_API_KEY",
"X_API_SECRET",
"X_ACCESS_TOKEN",
"X_ACCESS_TOKEN_SECRET",
] as const;
const missing = xKeys.filter((key) => !env[key]?.trim());
if (missing.length > 0) {
return { error: `${missing.join(", ")} must be set for read-only X access.\n` };
}

const source = resolveSource(env);
if (source.error !== undefined) return { error: source.error };

const defaultHandle = env.X_COMMUNITY_HANDLE?.trim().replace(/^@/u, "");
if (defaultHandle && !/^[A-Za-z0-9_]{1,15}$/u.test(defaultHandle)) {
return { error: "X_COMMUNITY_HANDLE is not a valid X username.\n" };
}

return {
config: {
port,
signingSecret,
botToken,
source: source.source,
xClient: createXCommunityClient({
apiKey: env.X_API_KEY!.trim(),
apiSecret: env.X_API_SECRET!.trim(),
accessToken: env.X_ACCESS_TOKEN!.trim(),
accessTokenSecret: env.X_ACCESS_TOKEN_SECRET!.trim(),
}),
...(defaultHandle ? { defaultHandle } : {}),
contextRoot:
contextRootOverride ?? join(process.cwd(), "tmp", SERVICE_NAME),
},
};
}
17 changes: 17 additions & 0 deletions starter/slack-community-pulse/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export { main, type MainOptions } from "./cli";
export {
createAgentStepInvoker,
defineCommunityPulseWorkflow,
description,
kind,
label,
WORKFLOW_ID,
} from "./workflow";
export {
createMentionTools,
createContentTools,
X_GET_MENTIONS,
X_GET_POSTS,
} from "./tools";
export { createXCommunityClient, type XCommunityClient } from "./x-client";
export { resolveSource, type ResolveResult, type Source } from "./source";
Loading