Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6fb5f15
chore(repo): add commit and secret hygiene workflow
treyyha Aug 30, 2026
2c3800f
feat(branding): rebrand app as AwlChat
treyyha Aug 30, 2026
57823ed
feat(templates): tailor campaigns for creator entrepreneurs
treyyha Aug 30, 2026
83593af
chore(config): align runtime and package metadata
treyyha Aug 30, 2026
bfa1d27
docs: rewrite README for AwlChat
treyyha Aug 30, 2026
cc79a6e
docs: update AwlChat security and contribution guides
treyyha Aug 30, 2026
0a733ef
build(deps): bump hono from 4.12.19 to 4.13.5 (#1)
dependabot[bot] Aug 30, 2026
031b97d
build(deps): bump nanoid from 3.3.11 to 3.3.18 (#2)
dependabot[bot] Aug 30, 2026
8605163
build(deps): bump fast-uri from 3.1.2 to 3.1.6 (#3)
dependabot[bot] Aug 30, 2026
0b66176
build(deps-dev): bump js-yaml from 4.1.1 to 4.3.2 (#4)
dependabot[bot] Aug 30, 2026
424a619
build(deps): bump @hono/node-server and prisma (#5)
dependabot[bot] Aug 30, 2026
e37aa10
build(deps): bump postcss and next (#6)
dependabot[bot] Aug 30, 2026
ee45a57
build(deps): bump @auth/core, @auth/prisma-adapter and next-auth (#8)
dependabot[bot] Aug 30, 2026
d30276a
build(deps): bump brace-expansion (#10)
dependabot[bot] Aug 30, 2026
12ba0fd
build(deps): bump esbuild from 0.28.0 to 0.28.2 (#12)
dependabot[bot] Aug 30, 2026
b949b5e
build(deps-dev): bump vite from 8.0.10 to 8.2.2 (#13)
dependabot[bot] Aug 30, 2026
3949bc4
feat(ui): add persistent dark mode
treyyha Aug 30, 2026
92939cb
fix(ui): apply dark theme styles to landing page
treyyha Aug 30, 2026
2dffec0
perf: reduce dashboard and report query overhead
treyyha Aug 30, 2026
282f39e
chore(auth): remove unused SMTP provider
treyyha Aug 30, 2026
caf4dc1
fix(auth): resolve base URLs per deployment environment
treyyha Aug 30, 2026
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
7 changes: 1 addition & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,13 @@ REDIS_URL=redis://localhost:6379

# Email magic links (Resend)
RESEND_API_KEY=re_...
EMAIL_FROM=OpenReply <login@example.com>
EMAIL_FROM=AwlChat <login@awlchat.co>

# Optional: restrict who can sign in. Without it, anyone who reaches your
# public URL can request a magic link and gets their own workspace. Comma
# separated, case insensitive.
# ALLOWED_EMAILS=you@example.com,teammate@example.com

# Optional: use your own SMTP server instead of Resend. When EMAIL_SERVER is
# set it takes over and RESEND_API_KEY is not needed. URL-encode special
# characters in user and password (@ becomes %40).
# EMAIL_SERVER=smtps://login%40example.com:password@mail.example.com:465

# Meta / Instagram
META_GRAPH_API_VERSION=v25.0
INSTAGRAM_APP_ID=your-instagram-app-id
Expand Down
2 changes: 2 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
node scripts/check-commit-message.mjs "$1"
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,22 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
# env files (only the documented template may be committed)
.env*
!.env.example

# local credentials and secret material
*.key
*.p12
*.pfx
**/*credential*
**/*secret*
**/*private-key*
**/*api-key*
**/*api_key*
**/*apikey*
**/*token*

# vercel
.vercel

Expand Down
34 changes: 33 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing

Thanks for wanting to help. OpenReply is public so the comment-to-DM engine is something you can read, run yourself, and improve.
Thanks for wanting to help. AwlChat is public so the comment-to-DM engine is something you can read, run yourself, and improve.

## Ways to help

Expand Down Expand Up @@ -31,6 +31,7 @@ npm run worker
Branch from `main`, keep the change focused on one thing, and make sure these pass:

```bash
node scripts/check-git-hygiene.mjs
npm run typecheck
npm run lint
npm test
Expand All @@ -39,6 +40,37 @@ npm run build

If a check cannot run in your environment, say why in the pull request body. A small, clear pull request is easier to merge than a large one that touches many things at once.

## Commits and secret hygiene

Use a Conventional Commit subject with one of these types:

```text
feat: add a capability
fix: correct broken behavior
chore: maintain tooling or dependencies
docs: update documentation
refactor: change structure without changing behavior
test: add or update tests
```

Optional scopes are supported, for example:

```text
feat(auth): restrict sign-in to allowed emails
```

The versioned `.githooks/commit-msg` hook validates this format. Enable it for
your local clone with:

```bash
git config core.hooksPath .githooks
```

Before staging, run `node scripts/check-git-hygiene.mjs`. Stage only the files related to
the change, never `.env`, `.env.local`, credentials, or API keys, and run the
check again after staging. The checker rejects tracked environment files,
secret-named files, and common secret signatures in staged content.

## A note on the codebase

This is Next.js 16, and some conventions differ from older versions. There are dev notes in `AGENTS.md`. When you are unsure about an API, read the relevant guide in `node_modules/next/dist/docs/` before writing against it.
Expand Down
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
<div align="center">

# OpenReply
# AwlChat

Open-sourced ManyChat for Instagram comment-to-DM automation.
Open-source ManyChat alternative for Instagram comment-to-DM automation.

[![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE)
[![Stars](https://img.shields.io/github/stars/diwenne/openreply?style=flat&color=black)](https://github.com/diwenne/openreply/stargazers)
[![Stars](https://img.shields.io/github/stars/treyyha/awlchat?style=flat&color=black)](https://github.com/treyyha/awlchat/stargazers)
[![Built with Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org)

</div>

Someone comments `LINK` on your reel, and they get a DM with your link a second later. That is the whole idea. OpenReply watches the comments on your Instagram posts, and when a comment matches a keyword you set, it sends that person a private reply through the official Meta API. You can also post a public reply under the comment at the same time.
Someone comments `LINK` on your reel, and they get a DM with your link a second later. That is the whole idea. AwlChat watches the comments on your Instagram posts, and when a comment matches a keyword you set, it sends that person a private reply through the official Meta API. You can also post a public reply under the comment at the same time.

ManyChat does this and charges a monthly fee. OpenReply is the same core feature, free, running on your own infrastructure, with no seat limits and no plan caps.
ManyChat does this and charges a monthly fee. AwlChat provides the same core feature, free, running on your own infrastructure, with no seat limits and no plan caps.

> **OpenReply is self-hosted. You have to deploy your own copy.**
> **AwlChat is self-hosted. You have to deploy your own copy.**
>
> [openreply.diwen.dev](https://openreply.diwen.dev) is a demo of the dashboard, not a service you can sign up for. Creating an account there will never send a DM for you, and there is no hosted plan to upgrade to.
> There is no shared AwlChat instance to sign up for. Deploy your own copy on your own domain so your Meta app can connect to it.
>
> Instagram automation runs against *your* Meta app, and Meta ties that app to a domain and a webhook URL you control. So a working instance means: your fork deployed, your domain pointed at it, your Meta app created, and your webhook registered. [docs/setup.md](docs/setup.md) walks through all of it.

Expand All @@ -26,7 +26,7 @@ ManyChat does this and charges a monthly fee. OpenReply is the same core feature

Comment-to-DM is one feature, but every tool that offers it wants a recurring subscription for it. The actual work is a webhook, a keyword match, and one API call to Meta. That does not need to cost anything to run for a single account.

OpenReply is built around Meta's official Instagram private replies. It does not scrape, it does not automate a browser, and it never asks for an Instagram password. That keeps your account inside Meta's rules, which matters if you care about not getting flagged.
AwlChat is built around Meta's official Instagram private replies. It does not scrape, it does not automate a browser, and it never asks for an Instagram password. That keeps your account inside Meta's rules, which matters if you care about not getting flagged.

## Features

Expand All @@ -35,7 +35,7 @@ OpenReply is built around Meta's official Instagram private replies. It does not
- DM and Story reply triggers. The same keywords can also fire on an inbound DM, which covers text replies to your Stories, since Instagram delivers those as DMs. That makes `Reply LINK to this Story` work with no post involved. Turn it on per campaign, and subscribe to the `messages` webhook field when you set up your Meta app.
- Tracked links. Swap a link for a tracked redirect and see clicks and CTR per campaign.
- Two link buttons. Send up to two tappable link buttons in one DM, each a separate tracked link with its own click stats.
- Follow gate. Optionally require a follow before you hand over the link. The DM asks the commenter to follow and tap a button; on tap, OpenReply checks Meta's `is_user_follow_business` flag and only sends the link once they follow, re-prompting until then. It fails open (sends the link anyway) when Instagram does not return follow status, so a real follower is never trapped.
- Follow gate. Optionally require a follow before you hand over the link. The DM asks the commenter to follow and tap a button; on tap, AwlChat checks Meta's `is_user_follow_business` flag and only sends the link once they follow, re-prompting until then. It fails open (sends the link anyway) when Instagram does not return follow status, so a real follower is never trapped.
- Personalization. Use `{username}` in your message to greet the commenter by name.
- Per-account rate limiting. Stays under Meta's documented cap of 750 private replies per hour, and queues the overflow instead of dropping it.
- Multiple Instagram accounts. Connect several professional accounts under one workspace, each with its own limits.
Expand All @@ -48,8 +48,8 @@ OpenReply is built around Meta's official Instagram private replies. It does not
## How it works

1. Someone comments on your Instagram post or reel, or DMs you, or replies to your Story.
2. Meta sends a webhook to your OpenReply instance.
3. OpenReply checks the text against your active campaigns.
2. Meta sends a webhook to your AwlChat instance.
3. AwlChat checks the text against your active campaigns.
4. On a keyword match, it queues a job.
5. A background worker sends the private reply, and the public reply if you enabled one.

Expand All @@ -65,13 +65,13 @@ The honest version: the code deploys in minutes, but the Meta app setup is the p

This is the part people skip. There is no shared instance to join — the button below creates *your* deployment, on *your* domain, which is the only thing your Meta app is allowed to talk to.

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/diwenne/openreply)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/treyyha/awlchat)

### Run it locally

```bash
git clone https://github.com/diwenne/openreply.git
cd openreply
git clone https://github.com/treyyha/awlchat.git
cd awlchat
npm install
cp .env.example .env # then fill in the values, see docs/setup.md
docker-compose up -d # starts Postgres and Redis
Expand Down Expand Up @@ -114,11 +114,11 @@ Built and maintained by Diwen Huang.
- X: [@diwenne](https://x.com/diwennee)
- Instagram: [@devdiwen](https://instagram.com/devdiwen)

OpenReply was initially forked from [instagram-comment-to-dm](https://github.com/im-anishraj/instagram-comment-to-dm) by [Anish Raj](https://github.com/im-anishraj), also MIT licensed, and has been substantially built upon since.
AwlChat is based on OpenReply, which was initially forked from [instagram-comment-to-dm](https://github.com/im-anishraj/instagram-comment-to-dm) by [Anish Raj](https://github.com/im-anishraj), also MIT licensed, and has been substantially built upon since. OpenReply was built and maintained by [Diwen Huang](https://github.com/diwenne).

## Star the repo

If OpenReply is useful to you, star it. It is the simplest way to help the project reach the next person looking for a free way to do this.
If AwlChat is useful to you, star it. It is the simplest way to help the project reach the next person looking for a free way to do this.

## License

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Security policy

OpenReply handles Instagram access tokens, webhook payloads, and campaign data. Please report security issues responsibly.
AwlChat handles Instagram access tokens, webhook payloads, and campaign data. Please report security issues responsibly.

## Supported versions

Expand Down
4 changes: 4 additions & 0 deletions __tests__/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ const { mockPrisma } = vi.hoisted(() => ({
dmLog: {
groupBy: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
linkClick: {
count: vi.fn(),
findMany: vi.fn(),
},
},
}));
Expand Down Expand Up @@ -68,6 +70,8 @@ beforeEach(() => {
dmSentAt: new Date("2026-05-20T12:00:00.000Z"),
createdAt: new Date("2026-05-20T12:00:00.000Z"),
});
mockPrisma.dmLog.findMany.mockResolvedValue([]);
mockPrisma.linkClick.findMany.mockResolvedValue([]);
mockPrisma.dmLog.count.mockResolvedValue(2);
});

Expand Down
20 changes: 9 additions & 11 deletions __tests__/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import {
describe("campaign templates", () => {
it("ships the public launch template set", () => {
expect(getCampaignTemplateSlugs()).toEqual([
"dtc-product-link",
"real-estate-lead-form",
"fitness-plan",
"course-webinar",
"beauty-price-list",
"restaurant-menu",
"event-rsvp",
"creator-media-kit",
"freebie-drop",
"digital-offer-link",
"waitlist-launch",
"discovery-call",
"newsletter-community",
"live-workshop",
"brand-collab-kit",
"content-os-stack",
]);
});

Expand All @@ -30,9 +30,7 @@ describe("campaign templates", () => {
});

it("finds templates by slug and returns null for unknown slugs", () => {
expect(getCampaignTemplate("dtc-product-link")?.title).toBe(
"DTC Product Link Drop"
);
expect(getCampaignTemplate("freebie-drop")?.title).toBe("Freebie Drop");
expect(getCampaignTemplate("missing-template")).toBeNull();
expect(getCampaignTemplate(undefined)).toBeNull();
});
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/campaigns/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ export default function CampaignsPage() {
onClick={() => toggleActive(auto.id, auto.isActive)}
className={`
relative w-11 h-6 rounded-full transition-colors
${auto.isActive ? "bg-accent" : "bg-zinc-300"}
${auto.isActive ? "bg-accent" : "bg-zinc-300 dark:bg-zinc-600"}
`}
>
<span
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default function DashboardPage() {
style={{ height: `${Math.max((day.count / maxDM) * 100, 4)}%` }}
/>
{/* Seven labels share a phone's width, so they must not wrap. */}
<span className="w-full truncate text-center text-[10px] text-zinc-500">
<span className="w-full truncate text-center text-[10px] text-muted">
{day.date}
</span>
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export default function InboxPage() {
<span className="truncate text-sm font-medium text-foreground">
@{c.contact.username ?? "unknown"}
</span>
<span className="shrink-0 text-[11px] text-zinc-500">
<span className="shrink-0 text-[11px] text-muted">
{formatTime(c.updatedTime)}
</span>
</div>
Expand Down Expand Up @@ -367,7 +367,7 @@ export default function InboxPage() {
<p className="whitespace-pre-wrap break-words">{m.text}</p>
<p
className={`mt-1 text-[10px] ${
m.fromMe ? "text-white/70" : "text-zinc-500"
m.fromMe ? "text-white/70" : "text-muted"
}`}
>
{formatTime(m.createdTime)}
Expand Down
16 changes: 10 additions & 6 deletions app/(dashboard)/overview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
*/

import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import AccountSelect from "@/components/account-select";
import StatCard from "@/components/stat-card";
import FollowerChart from "@/components/follower-chart";
import type { OverviewResponse } from "@/app/api/instagram/overview/route";

const FollowerChart = dynamic(() => import("@/components/follower-chart"), {
loading: () => <div className="panel h-72 rounded p-4 sm:p-6" />,
});

function formatNumber(n: number | null): string {
if (n === null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
Expand Down Expand Up @@ -76,8 +80,8 @@ export default function OverviewPage() {
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 sm:gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="panel rounded p-4 h-24 sm:p-5">
<div className="h-4 w-16 bg-zinc-200 rounded" />
<div className="mt-3 h-6 w-20 bg-zinc-200/60 rounded" />
<div className="h-4 w-16 rounded bg-zinc-200 dark:bg-zinc-700" />
<div className="mt-3 h-6 w-20 rounded bg-zinc-200/60 dark:bg-zinc-700/60" />
</div>
))}
</div>
Expand Down Expand Up @@ -126,7 +130,7 @@ export default function OverviewPage() {
</div>
<div className="flex flex-wrap items-end gap-x-4 gap-y-3">
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
Range
</span>
<select
Expand Down Expand Up @@ -197,7 +201,7 @@ export default function OverviewPage() {
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-zinc-500 border-b border-border">
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
<th className="py-2 pr-4 font-medium">Post</th>
<th className="py-2 px-3 font-medium text-right">Views</th>
<th className="py-2 px-3 font-medium text-right">Reach</th>
Expand Down Expand Up @@ -248,7 +252,7 @@ export default function OverviewPage() {
<td className="py-3 px-3 text-right text-muted">
{formatNumber(p.shares)}
</td>
<td className="py-3 pl-3 text-right text-zinc-500">
<td className="py-3 pl-3 text-right text-muted">
{formatDate(p.timestamp)}
</td>
</tr>
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export default function SettingsPage() {

{membersData?.invitations.length ? (
<div className="mt-6 border-t border-border pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted">
Pending invites
</p>
<div className="space-y-3">
Expand Down
Loading